---
phase: 04-tasks-kanban
plan: 03
type: execute
wave: 3
depends_on:
  - 04-02-PLAN.md
files_modified:
  - backend/internal/web/handlers_tasks.go
  - backend/templates/tasks.templ
  - backend/internal/web/handlers_tasks_test.go
autonomous: true
requirements:
  - TASK-03
  - TASK-04
  - TASK-05
  - TASK-07

must_haves:
  truths:
    - "Clicking a task card swaps it to an inline edit form showing current title and description"
    - "Saving the edit form updates title/description in the DB and swaps back to the display card"
    - "Cancelling edit restores the display card without a server POST"
    - "Dragging a task to a different column updates its status and position in the DB"
    - "Reordering tasks within a column updates their positions in the DB"
    - "GET /tablos/{id} after a reorder shows tasks in the saved position order"
    - "Reorder endpoint only modifies position and status — title and description are unchanged"
  artifacts:
    - path: "backend/internal/web/handlers_tasks.go"
      provides: "TaskEditHandler, TaskUpdateHandler, TaskReorderHandler — all fully implemented"
      exports: ["TaskEditHandler", "TaskUpdateHandler", "TaskReorderHandler"]
    - path: "backend/templates/tasks.templ"
      provides: "TaskEditFragment templ component"
      contains: "TaskEditFragment"
    - path: "backend/internal/web/handlers_tasks_test.go"
      provides: "TestTaskUpdate, TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskOrderPersists — all green"
      contains: "TestTaskReorderCrossColumn"
  key_links:
    - from: "browser Sortable.js onEnd"
      to: "POST /tablos/{id}/tasks/reorder"
      via: "hidden form#reorder-form triggered via htmx.trigger(form, 'submit')"
      pattern: "task_id|task_col"
    - from: "POST /tablos/{id}/tasks/reorder"
      to: "sqlc.UpdateTask"
      via: "loop over r.Form[task_id] with position=(index+1)*100"
      pattern: "UpdateTaskParams"
    - from: "GET /tablos/{id}/tasks/{task_id}/edit"
      to: "templates.TaskEditFragment"
      via: "outerHTML swap on .task-card-zone"
      pattern: "task-card-zone"
---

<objective>
Vertical slice 2: implement task inline editing (TASK-03), cross-column move via drag-and-drop (TASK-04), within-column reorder (TASK-05), and verify ordering persists (TASK-07). After this plan, the kanban board is fully functional — all 7 TASK requirements are delivered and all TestTask* integration tests pass.

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.
</objective>

<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>

<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.md

<interfaces>
<!-- Key contracts the executor needs. -->

From 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"
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: TaskEditHandler, TaskUpdateHandler — inline task editing</name>
  <files>backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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)
  </behavior>
  <action>
    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 ./...
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run TestTaskUpdate -v -count=1</automated>
  </verify>
  <done>
    `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).
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: TaskReorderHandler + Sortable.js inline script</name>
  <files>backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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)
  </behavior>
  <action>
    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 ./...
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run "TestTaskReorder" -v -count=1</automated>
  </verify>
  <done>
    `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).
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Turn all remaining TestTask* stubs GREEN + full suite gate</name>
  <files>backend/internal/web/handlers_tasks_test.go</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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)
  </behavior>
  <action>
    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
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 2>&1 | grep -E "^(--- PASS|--- FAIL|FAIL|ok)" | head -30</automated>
  </verify>
  <done>
    `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).
  </done>
</task>

</tasks>

<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>

<verification>
After all three tasks complete:
- `cd backend && go test ./...` exits 0 — full suite green
- `cd backend && go test ./internal/web/ -run TestTask -v` shows 9 PASS, 0 SKIP, 0 FAIL
- `grep -c 'TaskEditFragment' backend/templates/tasks.templ` returns 1 or more
- `grep -c 'htmx.onLoad' backend/templates/tasks.templ` returns 1 or more (Sortable.js init script)
- `grep -c 'r.ParseForm' backend/internal/web/handlers_tasks.go` returns 1 or more (reorder handler)
</verification>

<success_criteria>
Plan 03 complete when:
1. All 9 TestTask* integration tests pass (TASK-01 through TASK-07 fully covered)
2. `go test ./...` exits 0 — full suite green, no regressions
3. Inline task edit: GET .../edit returns edit form, POST saves and returns updated card
4. Reorder: POST /tablos/{id}/tasks/reorder updates status+position in DB, returns refreshed board
5. Sortable.js htmx.onLoad init script present in KanbanBoard component (Pitfall 2 protection)
6. Mass assignment guard: reorder fetches existing task before UpdateTask (T-04-08 mitigated)
</success_criteria>

<output>
After completion, create `.planning/phases/04-tasks-kanban/04-03-SUMMARY.md`
</output>
