summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-23 19:45:47 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-23 19:45:47 +0200
commitaedf5778aba87c4e8d036dde2a0b6ec79cf8b342 (patch)
tree059a8954c332d0a7c3e48e44906a4104d4338007
parentf993853db3e233f05a55de5ba2a87b77acf041aa (diff)
Keep in-editor asset curation off the head, layering it like every edit
ensure_autosave! gives body keystrokes and asset curation one shared layer, so head is never mutated in place and every curation change surfaces in the publish delta. Stale rendered join ids are mapped across the clone via asset_id. Curation now requires holding the lock; a missing lock answers 423, matching the autosave endpoint.
-rw-r--r--app/controllers/related_assets_controller.rb18
-rw-r--r--app/models/node.rb12
-rw-r--r--test/controllers/related_assets_controller_test.rb44
3 files changed, 55 insertions, 19 deletions
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb
index ca894f2f..3aa6d3db 100644
--- a/app/controllers/related_assets_controller.rb
+++ b/app/controllers/related_assets_controller.rb
@@ -2,6 +2,10 @@ class RelatedAssetsController < ApplicationController
2 before_action :login_required 2 before_action :login_required
3 before_action :find_node 3 before_action :find_node
4 4
5 rescue_from LockedByAnotherUser do
6 head :locked
7 end
8
5 def search 9 def search
6 term = params[:search_term].to_s.strip 10 term = params[:search_term].to_s.strip
7 attached_ids = @node.editable_page.related_assets.pluck(:asset_id) 11 attached_ids = @node.editable_page.related_assets.pluck(:asset_id)
@@ -19,8 +23,9 @@ class RelatedAssetsController < ApplicationController
19 end 23 end
20 24
21 def create 25 def create
26 page = @node.ensure_autosave!(current_user)
22 asset = Asset.find(params[:asset_id]) 27 asset = Asset.find(params[:asset_id])
23 related = @node.editable_page.related_assets.find_or_create_by!(asset: asset) 28 related = page.related_assets.find_or_create_by!(asset: asset)
24 29
25 render json: { 30 render json: {
26 id: related.id, 31 id: related.id,
@@ -35,22 +40,25 @@ class RelatedAssetsController < ApplicationController
35 end 40 end
36 41
37 def destroy 42 def destroy
38 @node.editable_page.related_assets.find(params[:id]).destroy 43 page = @node.ensure_autosave!(current_user)
44 source = RelatedAsset.find(params[:id])
45 page.related_assets.find_by!(:asset_id => source.asset_id).destroy
39 head :ok 46 head :ok
40 end 47 end
41 48
42 def update 49 def update
43 related = @node.editable_page.related_assets.find(params[:id]) 50 page = @node.ensure_autosave!(current_user)
51 source = RelatedAsset.find(params[:id])
52 related = page.related_assets.find_by!(:asset_id => source.asset_id)
44 53
45 if params.key?(:headline) 54 if params.key?(:headline)
46 RelatedAsset.transaction do 55 RelatedAsset.transaction do
47 @node.editable_page.related_assets.update_all(headline: false) 56 page.related_assets.update_all(headline: false)
48 related.update!(headline: true) if params[:headline] == "true" 57 related.update!(headline: true) if params[:headline] == "true"
49 end 58 end
50 else 59 else
51 related.insert_at(params[:position].to_i) 60 related.insert_at(params[:position].to_i)
52 end 61 end
53
54 head :ok 62 head :ok
55 end 63 end
56 64
diff --git a/app/models/node.rb b/app/models/node.rb
index 274b2f94..02502f6c 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -112,18 +112,18 @@ class Node < ApplicationRecord
112 end 112 end
113 end 113 end
114 114
115 # Creates or updates the autosave buffer from the given attributes. 115 def ensure_autosave! current_user
116 # Autosave rows are never associated to the node via node_id -- they
117 # must never appear in self.pages / the revisions list, which is the
118 # whole reason autosave exists as a separate, unversioned layer.
119 def autosave! attributes, current_user
120 assert_locked_by! current_user 116 assert_locked_by! current_user
121
122 unless self.autosave 117 unless self.autosave
123 self.autosave = Page.create!(:editor => current_user) 118 self.autosave = Page.create!(:editor => current_user)
124 self.autosave.clone_attributes_from(self.draft || self.head) if self.draft || self.head 119 self.autosave.clone_attributes_from(self.draft || self.head) if self.draft || self.head
125 self.save! 120 self.save!
126 end 121 end
122 self.autosave
123 end
124
125 def autosave! attributes, current_user
126 ensure_autosave!(current_user)
127 self.autosave.assign_attributes(attributes) 127 self.autosave.assign_attributes(attributes)
128 self.autosave.save! 128 self.autosave.save!
129 self.autosave 129 self.autosave
diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb
index ced4b74d..fd30dddd 100644
--- a/test/controllers/related_assets_controller_test.rb
+++ b/test/controllers/related_assets_controller_test.rb
@@ -38,19 +38,24 @@ class RelatedAssetsControllerTest < ActionController::TestCase
38 test "create attaches an asset to the node's editable page" do 38 test "create attaches an asset to the node's editable page" do
39 login_as :quentin 39 login_as :quentin
40 node = Node.root.children.create!(:slug => "related_assets_create_test") 40 node = Node.root.children.create!(:slug => "related_assets_create_test")
41 node.lock_for_editing!(users(:quentin))
41 asset = Asset.create!(:name => "erfa-photo", :upload_content_type => "image/png") 42 asset = Asset.create!(:name => "erfa-photo", :upload_content_type => "image/png")
42 43
43 post :create, params: { :node_id => node.id, :asset_id => asset.id } 44 post :create, params: { :node_id => node.id, :asset_id => asset.id }
44 45
45 assert_response :success 46 assert_response :success
46 assert_includes node.draft.reload.related_assets.map(&:asset_id), asset.id 47 current = node.reload.editable_page
48 assert_equal node.autosave, current, "mutation should have created an autosave layer"
49 assert_includes current.related_assets.map(&:asset_id), asset.id
47 json = JSON.parse(response.body) 50 json = JSON.parse(response.body)
48 assert json["url"].present? 51 assert json["url"].present?
52 assert_empty node.draft.reload.related_assets, "the draft must stay untouched"
49 end 53 end
50 54
51 test "create does not duplicate an already-attached asset" do 55 test "create does not duplicate an already-attached asset" do
52 login_as :quentin 56 login_as :quentin
53 node = Node.root.children.create!(:slug => "related_assets_dup_test") 57 node = Node.root.children.create!(:slug => "related_assets_dup_test")
58 node.lock_for_editing!(users(:quentin))
54 asset = Asset.create!(:name => "erfa-photo-2", :upload_content_type => "image/png") 59 asset = Asset.create!(:name => "erfa-photo-2", :upload_content_type => "image/png")
55 node.draft.assets << asset 60 node.draft.assets << asset
56 61
@@ -63,19 +68,22 @@ class RelatedAssetsControllerTest < ActionController::TestCase
63 test "destroy removes the attached asset" do 68 test "destroy removes the attached asset" do
64 login_as :quentin 69 login_as :quentin
65 node = Node.root.children.create!(:slug => "related_assets_destroy_test") 70 node = Node.root.children.create!(:slug => "related_assets_destroy_test")
71 node.lock_for_editing!(users(:quentin))
66 asset = Asset.create!(:name => "old-photo", :upload_content_type => "image/png") 72 asset = Asset.create!(:name => "old-photo", :upload_content_type => "image/png")
67 node.draft.assets << asset 73 node.draft.assets << asset
68 related = node.draft.related_assets.first
69 74
75 related = node.draft.related_assets.first
70 delete :destroy, params: { :node_id => node.id, :id => related.id } 76 delete :destroy, params: { :node_id => node.id, :id => related.id }
71 77
72 assert_response :success 78 assert_response :success
73 assert_equal 0, node.draft.reload.related_assets.count 79 assert_equal 0, node.reload.editable_page.related_assets.count
80 assert_equal 1, node.draft.reload.related_assets.count, "the draft must stay untouched"
74 end 81 end
75 82
76 test "update reorders the attached assets" do 83 test "update reorders the attached assets" do
77 login_as :quentin 84 login_as :quentin
78 node = Node.root.children.create!(:slug => "related_assets_reorder_test") 85 node = Node.root.children.create!(:slug => "related_assets_reorder_test")
86 node.lock_for_editing!(users(:quentin))
79 first = Asset.create!(:name => "first-photo", :upload_content_type => "image/png") 87 first = Asset.create!(:name => "first-photo", :upload_content_type => "image/png")
80 second = Asset.create!(:name => "second-photo", :upload_content_type => "image/png") 88 second = Asset.create!(:name => "second-photo", :upload_content_type => "image/png")
81 node.draft.assets << first 89 node.draft.assets << first
@@ -85,13 +93,15 @@ class RelatedAssetsControllerTest < ActionController::TestCase
85 patch :update, params: { :node_id => node.id, :id => second_related.id, :position => 1 } 93 patch :update, params: { :node_id => node.id, :id => second_related.id, :position => 1 }
86 94
87 assert_response :success 95 assert_response :success
88 ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id) 96 ordered_asset_ids = node.reload.editable_page.related_assets.order(:position).map(&:asset_id)
97 # XXXX ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id)
89 assert_equal [second.id, first.id], ordered_asset_ids 98 assert_equal [second.id, first.id], ordered_asset_ids
90 end 99 end
91 100
92 test "update sets the headline flag" do 101 test "update sets the headline flag" do
93 login_as :quentin 102 login_as :quentin
94 node = Node.root.children.create!(:slug => "related_assets_headline_test") 103 node = Node.root.children.create!(:slug => "related_assets_headline_test")
104 node.lock_for_editing!(users(:quentin))
95 asset = Asset.create!(:name => "headline-photo", :upload_content_type => "image/png") 105 asset = Asset.create!(:name => "headline-photo", :upload_content_type => "image/png")
96 node.draft.assets << asset 106 node.draft.assets << asset
97 related = node.draft.related_assets.find_by(:asset_id => asset.id) 107 related = node.draft.related_assets.find_by(:asset_id => asset.id)
@@ -99,12 +109,14 @@ class RelatedAssetsControllerTest < ActionController::TestCase
99 patch :update, params: { :node_id => node.id, :id => related.id, :headline => "true" } 109 patch :update, params: { :node_id => node.id, :id => related.id, :headline => "true" }
100 110
101 assert_response :success 111 assert_response :success
102 assert related.reload.headline? 112 assert node.reload.editable_page.related_assets.find_by!(:asset_id => asset.id).headline?
113 assert_not related.reload.headline?, "the draft must stay untouched"
103 end 114 end
104 115
105 test "update with headline=true clears any previous headline on the same page" do 116 test "update with headline=true clears any previous headline on the same page" do
106 login_as :quentin 117 login_as :quentin
107 node = Node.root.children.create!(:slug => "related_assets_headline_swap_test") 118 node = Node.root.children.create!(:slug => "related_assets_headline_swap_test")
119 node.lock_for_editing!(users(:quentin))
108 first = Asset.create!(:name => "first-headline", :upload_content_type => "image/png") 120 first = Asset.create!(:name => "first-headline", :upload_content_type => "image/png")
109 second = Asset.create!(:name => "second-headline", :upload_content_type => "image/png") 121 second = Asset.create!(:name => "second-headline", :upload_content_type => "image/png")
110 node.draft.assets << first 122 node.draft.assets << first
@@ -117,13 +129,16 @@ class RelatedAssetsControllerTest < ActionController::TestCase
117 patch :update, params: { :node_id => node.id, :id => second_related.id, :headline => "true" } 129 patch :update, params: { :node_id => node.id, :id => second_related.id, :headline => "true" }
118 130
119 assert_response :success 131 assert_response :success
120 assert_not first_related.reload.headline? 132 current = node.reload.editable_page
121 assert second_related.reload.headline? 133 assert_not current.related_assets.find_by!(:asset_id => first.id).headline?
134 assert current.related_assets.find_by!(:asset_id => second.id).headline?
135 assert first_related.reload.headline?, "the draft must stay untouched"
122 end 136 end
123 137
124 test "update with headline=false clears the headline" do 138 test "update with headline=false clears the headline" do
125 login_as :quentin 139 login_as :quentin
126 node = Node.root.children.create!(:slug => "related_assets_headline_unset_test") 140 node = Node.root.children.create!(:slug => "related_assets_headline_unset_test")
141 node.lock_for_editing!(users(:quentin))
127 asset = Asset.create!(:name => "unset-headline", :upload_content_type => "image/png") 142 asset = Asset.create!(:name => "unset-headline", :upload_content_type => "image/png")
128 node.draft.assets << asset 143 node.draft.assets << asset
129 related = node.draft.related_assets.find_by(:asset_id => asset.id) 144 related = node.draft.related_assets.find_by(:asset_id => asset.id)
@@ -132,7 +147,8 @@ class RelatedAssetsControllerTest < ActionController::TestCase
132 patch :update, params: { :node_id => node.id, :id => related.id, :headline => "false" } 147 patch :update, params: { :node_id => node.id, :id => related.id, :headline => "false" }
133 148
134 assert_response :success 149 assert_response :success
135 assert_not related.reload.headline? 150 assert_not node.reload.editable_page.related_assets.find_by!(:asset_id => asset.id).headline?
151 assert related.reload.headline?, "the draft must stay untouched"
136 end 152 end
137 153
138 test "search includes PDF assets as headline-eligible candidates" do 154 test "search includes PDF assets as headline-eligible candidates" do
@@ -159,4 +175,16 @@ class RelatedAssetsControllerTest < ActionController::TestCase
159 ids = JSON.parse(response.body).map { |r| r["id"] } 175 ids = JSON.parse(response.body).map { |r| r["id"] }
160 assert_includes ids, asset.id 176 assert_includes ids, asset.id
161 end 177 end
178
179 test "curation without holding the lock is refused with 423" do
180 login_as :quentin
181 node = Node.root.children.create!(:slug => "curation_lock_test")
182 asset = Asset.create!(:name => "Untouchable", :upload_content_type => "image/png")
183 node.lock_for_editing!(users(:aaron))
184
185 post :create, params: { :node_id => node.id, :asset_id => asset.id }
186
187 assert_response :locked
188 assert_empty node.draft.assets.reload
189 end
162end 190end