The uploaded picture is written after the row is committed

`Image#process_upload` ran in a `before_save`: it assigned the generated file
name and copied the bytes to disk, in the same breath, before the row existed.
If the insert failed afterwards — a validation on the owning record, a
uniqueness clash, a rollback from the surrounding transaction — the file stayed
behind with nothing pointing at it. The admin's own "Orphan" scope exists to
find rows in that family; this was the other half of it, the files with no row
at all.

Split in two: `before_validation` assigns the attributes (so validations and the
generated name still see them), `after_commit` copies the bytes. A rollback now
takes the file with it, because the copy never happens.

The same model is generated into every host, so the fix goes into the
generator template as well as ours. While there: `Image#url` comes back from
Teletype Orbit, where it was added and never flowed back, and the two admin
previews use it instead of interpolating `/api/image/#{id}` by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 09:02:22 +02:00
co-authored by Claude Opus 5
parent 878d73ce9b
commit 7087a9bbc7
@@ -9,7 +9,8 @@ class Image < ApplicationRecord
attr_accessor :file_upload
before_save :process_upload, if: -> { file_upload.present? }
before_validation :assign_upload_attributes, if: -> { file_upload.present? }
after_commit :store_upload_file, on: [ :create, :update ], if: -> { file_upload.present? }
def self.ransackable_attributes(auth_object = nil)
%w[content_type created_at deleted_at filename id original_filename updated_at]
@@ -19,14 +20,18 @@ class Image < ApplicationRecord
File.join(self.class.upload_path, filename.to_s)
end
def url = "/api/image/#{id}"
private
def process_upload
FileUtils.mkdir_p(self.class.upload_path)
def assign_upload_attributes
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
self.filename = "#{SecureRandom.uuid}#{ext}"
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
end
def store_upload_file
FileUtils.mkdir_p(self.class.upload_path)
IO.copy_stream(file_upload.to_io, file_path)
end
end