Use DELETE on completion resource for undo
Change undo from POST /undo_complete to DELETE /complete. The undo
button uses a fetch DELETE via Alpine.js instead of a form POST,
avoiding CSRF token plumbing since CSRF checks only apply to POST.
https://claude.ai/code/session_01NLGBcJgbiyWGDBGFh8DRas
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 4f3ebdf..7353f27 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -45,10 +45,10 @@ module Views
div(class: "flash-bar") do
span(class: "flash-message") { @flash["message"] }
if undo_path
- form(method: "post", action: undo_path, class: "flash-undo-form") do
- input(type: "hidden", name: "_csrf", value: @csrf.call(undo_path))
- button(type: "submit", class: "flash-undo-btn") { "Undo" }
- end
+ button(
+ class: "flash-undo-btn",
+ "x-on:click": "fetch('#{undo_path}', {method: 'DELETE'}).then(() => location.reload())"
+ ) { "Undo" }
end
end
end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index d03a61d..f0341f8 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -144,26 +144,28 @@ class Web < Roda
r.on "tasks", Integer do |task_id|
@task = @series.tasks_dataset.where(id: task_id).sole
- r.post "complete" do
- r.halt 422 unless @task[:completed_at].nil?
- @task.complete!(today: Date.today)
-
- note_title = @series.note.lines.first&.strip || @series.note
- undo_path = "/series/#{series_id}/tasks/#{@task.id}/undo_complete"
- session["flash"] = { "message" => "Completed \u201c#{note_title}\u201d", "undo_path" => undo_path }
-
- return_to = r.params["return_to"]
- if return_to && return_to.start_with?("/")
- r.redirect return_to
- else
- r.redirect "/"
+ r.on "complete" do
+ r.post do
+ r.halt 422 unless @task[:completed_at].nil?
+ @task.complete!(today: Date.today)
+
+ note_title = @series.note.lines.first&.strip || @series.note
+ complete_path = "/series/#{series_id}/tasks/#{@task.id}/complete"
+ session["flash"] = { "message" => "Completed \u201c#{note_title}\u201d", "undo_path" => complete_path }
+
+ return_to = r.params["return_to"]
+ if return_to && return_to.start_with?("/")
+ r.redirect return_to
+ else
+ r.redirect "/"
+ end
end
- end
- r.post "undo_complete" do
- r.halt 422 if @task[:completed_at].nil?
- @task.undo_complete!
- r.redirect "/"
+ r.delete do
+ r.halt 422 if @task[:completed_at].nil?
+ @task.undo_complete!
+ r.redirect "/"
+ end
end
r.is do
diff --git a/test/test_web.rb b/test/test_web.rb
index 0271ded..d738d10 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -595,7 +595,7 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, "Completed"
assert_includes last_response.body, "Call Mom"
assert_includes last_response.body, "Undo"
- assert_includes last_response.body, "undo_complete"
+ assert_includes last_response.body, "/complete"
end
def test_flash_is_cleared_after_display
@@ -613,7 +613,7 @@ class TestWeb < Minitest::Test
refute_includes last_response.body, "flash-bar"
end
- def test_undo_complete_restores_task
+ def test_delete_complete_restores_task
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
first_due_date: (Date.today - 3).to_s)
@@ -624,26 +624,24 @@ class TestWeb < Minitest::Test
assert_equal 2, DB[:tasks].where(series_id: series[:id]).count
refute_nil DB[:tasks].first(id: task[:id])[:completed_at]
- csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/undo_complete", {}, auth_headers
+ delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
assert last_response.redirect?
assert_nil DB[:tasks].first(id: task[:id])[:completed_at]
assert_equal 1, DB[:tasks].where(series_id: series[:id]).count
end
- def test_undo_complete_rejects_active_task
+ def test_delete_complete_rejects_active_task
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
first_due_date: "2026-03-01")
task = DB[:tasks].first
series = DB[:series].first
- csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/undo_complete", {}, auth_headers
- # No undo form exists for an active task, so CSRF rejects (403) before
- # the route-level 422 check. Either way, the request is blocked.
- assert_includes [403, 422], last_response.status
+ delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+ assert_equal 422, last_response.status
end
- def test_undo_complete_requires_own_task
+ def test_delete_complete_requires_own_task
create_series(
note: "Alice task", interval_unit: "day", interval_count: "1",
first_due_date: (Date.today - 1).to_s,
@@ -654,7 +652,7 @@ class TestWeb < Minitest::Test
series = DB[:series].first
csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "alice@example.com")
- csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/undo_complete", {}, auth_headers(login: "bob@example.com")
+ delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "bob@example.com")
assert_includes [403, 404], last_response.status
end