Wave 1 (Plan 01): DB migration, sqlc queries, RED test scaffold, Sortable.js bootstrap, soft-danger CSS. Wave 2 (Plan 02): Kanban board render + task create + task delete vertical slice (TASK-01, TASK-02, TASK-06). Wave 3 (Plan 03): Inline task edit + Sortable.js drag reorder/move (TASK-03, TASK-04, TASK-05, TASK-07). Wave 4 (Plan 04): Human-verify checkpoint — full browser verification of all 7 TASK requirements. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
21 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-tasks-kanban | 03 | execute | 3 |
|
|
true |
|
|
Purpose: Completes the kanban board. Sortable.js wiring + reorder endpoint closes TASK-04/05/07; inline edit closes TASK-03. The phase is done when go test ./... is green.
Output: TaskEditHandler, TaskUpdateHandler, TaskReorderHandler fully implemented; TaskEditFragment templ component; all 9 TestTask* tests passing.
<execution_context> @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md </execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-CONTEXT.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-RESEARCH.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-UI-SPEC.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-02-SUMMARY.mdFrom backend/internal/web/handlers_tasks.go (Plan 02 output — stubs to replace): // TaskEditHandler — currently returns http.Error 501 func TaskEditHandler(deps TasksDeps) http.HandlerFunc { ... } // TaskUpdateHandler — currently returns http.Error 501 func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc { ... } // TaskReorderHandler — currently returns http.Error 501 func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { ... }
From backend/internal/db/sqlc/ (generated after Plan 01): // UpdateTask signature (combined status+position+title+desc update): func (q *Queries) UpdateTask(ctx, params UpdateTaskParams) (Task, error) type UpdateTaskParams struct { ID uuid.UUID Title string Description pgtype.Text Status TaskStatus Position int32 } // GetTaskByID: func (q *Queries) GetTaskByID(ctx, params GetTaskByIDParams) (Task, error) type GetTaskByIDParams struct { ID uuid.UUID; TabloID uuid.UUID }
Reorder payload format (RESEARCH.md Pattern 3 + D-07): r.Form["task_id"] — ordered array of task UUIDs (one entry per task, in new visual order) r.Form["task_col"] — parallel array of new column status strings (same length as task_id) Position computed as: (index+1) * 100 — e.g. first task in array gets position=100, second=200... gorilla/csrf reads _csrf from r.PostFormValue; call r.ParseForm() before accessing r.Form["task_id"]
UI-SPEC §3 edit interaction contracts:
- GET /tablos/{id}/tasks/{task_id}/edit → returns TaskEditFragment (hx-target="closest .task-card-zone" hx-swap="outerHTML")
- POST /tablos/{id}/tasks/{task_id} → on success returns TaskCard (same outerHTML swap target)
- "Discard changes" button: hx-get=".../show" hx-target="closest .task-card-zone" hx-swap="outerHTML" — restores without POST
- TaskEditFragment outer wrapper must have class="task-card-zone" id="task-{task_id}" (for outerHTML round-trips)
Reorder full-board refresh (RESEARCH.md Open Question 2 recommendation + D-07):
- After reorder POST: re-fetch all tasks for the tablo, return updated KanbanBoard outerHTML
- hx-target="#kanban-board" hx-swap="outerHTML" on the hidden reorder form
- Sortable.js re-initialized via htmx.onLoad (Pitfall 2 from RESEARCH.md)
From backend/templates/tasks.templ (Plan 02 output): // TaskCard outer wrapper: class="task-card-zone" id={"task-"+task.ID.String()} // The edit trigger: hx-get="/tablos/{id}/tasks/{task_id}/edit" // hx-target="closest .task-card-zone" // hx-swap="outerHTML"
Task 1: TaskEditHandler, TaskUpdateHandler — inline task editing backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ - backend/internal/web/handlers_tasks.go (current state — TaskEditHandler and TaskUpdateHandler stubs to replace; also read loadOwnedTabloForTask implementation) - backend/internal/web/handlers_tablos.go (TabloUpdateHandler — exact pattern for validation + HTMX fragment response to mirror) - backend/templates/tasks.templ (current state — TaskCard component structure; TaskEditFragment must use same .task-card-zone outer wrapper + id) - backend/internal/db/sqlc/ (UpdateTaskParams fields — confirm Status and Position are required) - GET /tablos/{id}/tasks/{task_id}/edit returns 200 + TaskEditFragment HTML with title input pre-filled with task.Title, description textarea pre-filled with task.Description.String (empty string if !Valid) - GET .../edit by non-owner (different user's tablo) returns 404 - POST /tablos/{id}/tasks/{task_id} with HX-Request:true, title="Updated title", description="New desc" returns 200 + TaskCard HTML containing "Updated title" and "New desc" - POST /tablos/{id}/tasks/{task_id} with title="" returns 422 + TaskEditFragment HTML containing "Title is required." - POST /tablos/{id}/tasks/{task_id} without HX-Request header returns 303 redirect to /tablos/{id} - After successful POST, GetTaskByID from DB shows Title="Updated title" and Description.String="New desc" - UpdateTask preserves existing Status and Position values (does not reset them) In `backend/internal/web/handlers_tasks.go`, replace the 501-stub bodies of TaskEditHandler and TaskUpdateHandler:TaskEditHandler(deps): call loadOwnedTabloForTask. Return 200 + TaskEditFragment(tabloID, task, TaskUpdateForm{Title: task.Title, Description: task.Description.String}, TaskUpdateErrors{}, csrfToken).
TaskUpdateHandler(deps): call loadOwnedTabloForTask. Read title = strings.TrimSpace(r.PostFormValue("title")) and description = r.PostFormValue("description"). Validate title non-empty and <=255 chars. On error: 422 + TaskEditFragment with errors (HTMX) or 422 + redirect (non-HTMX). On success: call UpdateTask with UpdateTaskParams{ID: task.ID, Title: title, Description: pgtype.Text{String: description, Valid: description != ""}, Status: task.Status, Position: task.Position} — preserves existing Status and Position (only title/description change). HTMX: return 200 + TaskCard(tabloID, updatedTask, csrfToken). Non-HTMX: 303 to /tablos/{tablo.ID.String()}.
Add TaskEditFragment to `backend/templates/tasks.templ`:
Signature: TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, form templates.TaskUpdateForm, errs templates.TaskUpdateErrors, csrfToken string).
Outer div: class="task-card-zone" id={"task-"+task.ID.String()} (same as TaskCard wrapper — enables outerHTML round-trip).
Form: method POST, action="/tablos/{tabloID}/tasks/{taskID}", hx-post same URL, hx-target="closest .task-card-zone", hx-swap="outerHTML".
Fields: title input (name="title", value={form.Title}, maxlength="255", required, same styling as TaskCreateForm). Description textarea (name="description", rows="3", placeholder="Description (optional)", value={form.Description}). @FieldError(errs.Title) if non-empty. @FieldError(errs.General) if non-empty. @ui.CSRFField(csrfToken). "Save changes" button (ui.Button solid default md, type="submit"). "Discard changes" button (ui.Button soft neutral md) with hx-get="/tablos/{tabloID}/tasks/{taskID}/show" hx-target="closest .task-card-zone" hx-swap="outerHTML".
After editing, run: cd backend && just generate && go build ./...
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run TestTaskUpdate -v -count=1
`just generate` exits 0. `go build ./...` exits 0. TestTaskUpdate passes. `grep -c 'TaskEditFragment' templates/tasks.templ` returns 1 or more. `grep -c 'TaskEditHandler' internal/web/handlers_tasks.go` returns 1 or more (non-stub implementation).
Task 2: TaskReorderHandler + Sortable.js inline script
backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ
- backend/internal/web/handlers_tasks.go (current state — TaskReorderHandler stub to replace; also read TaskColumns, TaskColumnLabels)
- backend/templates/tasks.templ (current state — KanbanBoard hidden reorder form; location to add Sortable.js inline script)
- backend/internal/db/sqlc/ (UpdateTaskParams, ListTasksByTablo — confirm signatures)
- backend/internal/auth/csrf.go (how csrf.RequestHeader is configured — confirm gorilla/csrf reads _csrf from form field)
- POST /tablos/{id}/tasks/reorder with task_id[]=uuid1&task_col[]=in_progress&task_id[]=uuid2&task_col[]=todo returns 200 + KanbanBoard outerHTML with both tasks in their new columns
- After reorder POST, uuid1 has status=in_progress position=100 in DB; uuid2 has status=todo position=100 in DB (positions renumbered from (index+1)*100)
- POST /tablos/{id}/tasks/reorder by non-owner returns 404
- POST /tablos/{id}/tasks/reorder with mismatched task_id and task_col array lengths returns 400
- GET /tablos/{id} after a reorder POST shows tasks in the order returned by ListTasksByTablo (status ORDER BY, then position)
- Sortable.js onEnd fires htmx.trigger(form, "submit") on the #reorder-form element
- Invalid task UUIDs in the reorder payload are skipped silently (last-write-wins per D-05)
In `backend/internal/web/handlers_tasks.go`, replace TaskReorderHandler stub:
TaskReorderHandler(deps): call loadOwnedTablo (not loadOwnedTabloForTask — reorder operates at the tablo level, not per-task). Call r.ParseForm() explicitly before accessing r.Form arrays. Read taskIDs := r.Form["task_id"] and taskCols := r.Form["task_col"]. If len(taskIDs) != len(taskCols), return 400. Loop: for i, rawID := range taskIDs { parse uuid, skip on error; newPos := int32((i+1) * 100); newStatus := sqlc.TaskStatus(taskCols[i]); call UpdateTask with UpdateTaskParams{ID: taskID, Title: existingTask.Title, Description: existingTask.Description, Status: newStatus, Position: newPos} }. Note: to preserve Title/Description during reorder, fetch each task first with GetTaskByID before updating, OR use a separate sqlc query that only updates status+position. Use the latter approach: add a comment noting this is intentional (RESEARCH.md anti-pattern: mass assignment — reorder only touches position/status). After loop, re-fetch with ListTasksByTablo and return KanbanBoard outerHTML. Set Content-Type: text/html.
Important: the UpdateTask sqlc query updates title, description, status, and position. To avoid mass assignment (T-04-MASS), fetch the task first using GetTaskByID, then pass back the existing title/desc alongside new status/position to UpdateTask. This is the safest pattern given the existing query shape.
Add the Sortable.js inline initialization script to `backend/templates/tasks.templ`, inside KanbanBoard, after the column divs. The script must:
- Wrap all initialization in `htmx.onLoad(function(content) { ... })` to reinitialize after every HTMX swap (Pitfall 2 from RESEARCH.md)
- For each .sortable-column element: new Sortable(col, { group: "kanban", animation: 150, handle: ".task-drag-handle", draggable: ".task-card", ghostClass: "bg-slate-100", chosenClass: "opacity-50", onEnd: function(evt) { ... } })
- The onEnd callback: (1) get the reorder form: var form = document.getElementById("reorder-form"); (2) clear previous dynamic inputs: form.querySelectorAll("input[name=task_id],input[name=task_col]").forEach(function(el){el.remove();}); (3) iterate all .sortable-column elements, for each card (data-task-id) append hidden task_id input and task_col input (value=col.dataset.status); (4) trigger: htmx.trigger(form, "submit")
- This is approximately 15-20 lines; the full script lives in the templ file as a <script> block (not a separate .js file — per D-07 "thin event bridge")
After editing, run: cd backend && just generate && go build ./...
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run "TestTaskReorder" -v -count=1
`just generate` exits 0. `go build ./...` exits 0. TestTaskReorderCrossColumn and TestTaskReorderSameColumn both pass. `grep -c 'htmx.onLoad' templates/tasks.templ` returns 1 or more. `grep -c 'TaskReorderHandler' internal/web/handlers_tasks.go` returns 1 or more (non-stub body with r.ParseForm() call).
Task 3: Turn all remaining TestTask* stubs GREEN + full suite gate
backend/internal/web/handlers_tasks_test.go
- backend/internal/web/handlers_tasks_test.go (current state — TestTaskUpdate, TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskOrderPersists still SKIP'd)
- backend/internal/web/handlers_tasks.go (final implementation — confirm all routes, handler signatures, response shapes for reorder + edit)
- backend/internal/web/handlers_tablos_test.go (full file — patterns for building form-encoded bodies with multiple values)
- TestTaskUpdate: edit a pre-inserted task via GET .../edit then POST with new title/desc; assert 200 + updated card in body; verify DB row updated
- TestTaskReorderCrossColumn: insert 2 tasks in "todo", POST reorder with task2 moved to "in_progress"; assert task2.Status="in_progress" position=100 in DB
- TestTaskReorderSameColumn: insert 3 tasks in "todo" (pos 100,200,300), POST reorder with reversed order; assert positions renumbered (pos 100,200,300 assigned to the reversed IDs)
- TestTaskOrderPersists: after reorder POST, GET /tablos/{id} response body contains tasks in the new order (first task in new order appears before second task in HTML response)
In `backend/internal/web/handlers_tasks_test.go`, replace t.Skip() bodies of TestTaskUpdate, TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskOrderPersists with real integration assertions.
TestTaskUpdate: pre-insert tablo + task. GET /tablos/{id}/tasks/{task_id}/edit with HTMX header — assert 200 + body contains task title. Then POST /tablos/{id}/tasks/{task_id} with title="Updated" description="New desc" — assert 200 + body contains "Updated". Fetch task from DB with q.GetTaskByID and assert Title="Updated" Description.String="New desc".
TestTaskReorderCrossColumn: pre-insert tablo + 2 tasks in "todo" (positions 100, 200). Build form body: task_id={task2.ID}&task_col=in_progress&task_id={task1.ID}&task_col=todo (task2 moved to in_progress, task1 stays todo). POST to /tablos/{tablo.ID}/tasks/reorder with HTMX header. Assert 200 + body contains "kanban-board". Fetch task2 from DB: assert task2.Status="in_progress".
TestTaskReorderSameColumn: pre-insert tablo + 3 tasks in "todo" (pos 100,200,300). Build form body with reversed order: task3 first (task_col=todo), then task2 (task_col=todo), then task1 (task_col=todo). POST. Assert 200. Fetch all 3 tasks from DB. Assert tasks sorted by position have IDs in reversed order.
TestTaskOrderPersists: after a reorder POST (same as TestTaskReorderSameColumn), GET /tablos/{tablo.ID} and assert the response body contains task3.Title before task1.Title (positional ordering reflected in rendered HTML). Use strings.Index to compare positions.
Form encoding for multiple values of same key: use url.Values with .Add() multiple times for the same key, e.g.:
form := url.Values{}
form.Add("task_id", task1.ID.String())
form.Add("task_col", "todo")
form.Add("task_id", task2.ID.String())
form.Add("task_col", "in_progress")
strings.NewReader(form.Encode())
Run the full suite after: cd backend && go test ./... -v
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 2>&1 | grep -E "^(--- PASS|--- FAIL|FAIL|ok)" | head -30
`go test ./...` exits 0. All TestTask* functions show PASS (none SKIP, none FAIL). Full suite (`go test ./...`) is green — no FAIL lines in output. `grep -c 'TestTaskOrderPersists' internal/web/handlers_tasks_test.go` returns 1 (not a stub).
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → POST /tablos/{id}/tasks/reorder | task_id array and task_col array come from user-controlled Sortable.js DOM read |
| Browser → POST /tablos/{id}/tasks/{task_id} (edit) | title and description from user input |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-08 | Tampering | TaskReorderHandler mass assignment | mitigate | Reorder only updates status+position; title/description preserved by fetching existing row first (GetTaskByID) before calling UpdateTask |
| T-04-09 | Tampering | task_col array in reorder payload | mitigate | sqlc.TaskStatus(taskCols[i]) is passed to DB; Postgres ENUM rejects invalid column values at DB layer |
| T-04-10 | Elevation of Privilege | TaskReorderHandler task_id injection | mitigate | UpdateTask query uses WHERE id=$1 AND tablo_id=$2 implicitly via loadOwnedTablo; tasks from other tablos are rejected at the DB query level |
| T-04-11 | Denial of Service | Reorder POST with huge task_id array | accept | v1 acceptable; task counts are small; no explicit limit added (D-05 last-write-wins) |
| T-04-12 | Tampering | TaskUpdateHandler preserves status/position | mitigate | TaskUpdateHandler reads existing task.Status and task.Position and passes them unchanged to UpdateTask; only title+description can change via this endpoint |
| </threat_model> |
<success_criteria> Plan 03 complete when:
- All 9 TestTask* integration tests pass (TASK-01 through TASK-07 fully covered)
go test ./...exits 0 — full suite green, no regressions- Inline task edit: GET .../edit returns edit form, POST saves and returns updated card
- Reorder: POST /tablos/{id}/tasks/reorder updates status+position in DB, returns refreshed board
- Sortable.js htmx.onLoad init script present in KanbanBoard component (Pitfall 2 protection)
- Mass assignment guard: reorder fetches existing task before UpdateTask (T-04-08 mitigated) </success_criteria>