xtablo-source/.planning/phases/03-tablos-crud/03-03-PLAN.md
Arthur Belleville f53b54637b
docs(03): plan phase 3 — Tablos CRUD (3 plans, 3 waves)
Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration
+ sqlc + test scaffold + button CSS), list+create (dashboard, inline
form, OOB swap), and detail+edit+delete (ownership 404, inline edit
fragments, inline confirm delete). Includes Nyquist VALIDATION.md and
PATTERNS.md with real analog excerpts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:08:08 +02:00

29 KiB

phase plan type wave depends_on files_modified autonomous requirements tags must_haves
03-tablos-crud 03 execute 3
01
02
backend/internal/web/handlers_tablos.go
backend/templates/tablos.templ
backend/templates/tablos_templ.go
backend/internal/web/router.go
false
TABLO-03
TABLO-04
TABLO-05
TABLO-06
go
htmx
templ
chi
csrf
ownership
truths artifacts key_links
Owner can GET /tablos/{id} and see the detail page with title + description rendered
Non-owner GET /tablos/{id} returns 404 (not 403)
Malformed UUID at /tablos/{id} returns 404
Owner can click title/description, swap to edit input, save via POST /tablos/{id}, see updated display fragment
Owner can click Delete on dashboard card or detail page, see inline confirmation, confirm to remove the tablo, or cancel to restore the original button
All mutating routes accept and validate CSRF and degrade gracefully for non-HTMX clients (303 redirects)
path provides contains
backend/internal/web/handlers_tablos.go TabloDetailHandler, TabloEditTitleHandler, TabloEditDescHandler, TabloShowTitleHandler, TabloShowDescHandler, TabloUpdateHandler, TabloDeleteConfirmHandler, TabloDeleteCancelHandler, TabloDeleteHandler TabloDeleteHandler
path provides contains
backend/templates/tablos.templ TabloDetailPage, TabloTitleDisplay, TabloTitleEditFragment, TabloDescDisplay, TabloDescEditFragment, TabloDeleteButtonFragment, TabloDeleteConfirmFragment, TabloNotFoundPage TabloDeleteConfirmFragment
path provides contains
backend/internal/web/router.go All remaining /tablos/{id}* route registrations in correct order TabloDeleteHandler
from to via pattern
TabloDetailHandler ownership check tablo.UserID != user.ID → http.NotFound http.NotFound
from to via pattern
TabloUpdateHandler Queries.UpdateTablo sqlc binding with updated_at = now() UpdateTablo
from to via pattern
TabloDeleteHandler Queries.DeleteTablo sqlc binding DeleteTablo
Second vertical slice of Phase 3: detail page, inline edit (title + description), and inline-confirmation delete. Turns the remaining six TABLO tests green (TestTabloDetail_Owner, TestTabloDetail_NonOwner, TestTabloDetail_InvalidID, TestTabloUpdate, TestTabloDeleteConfirm, TestTabloDelete) and closes TABLO-03, TABLO-04, TABLO-05, TABLO-06.

Purpose: Complete the Tablos CRUD vertical so the user story shipped in Plan 02 (create + list) extends to read, update, and delete — all HTMX-driven with degradation to plain POST.

Output:

  • Detail page handler + ownership-enforced 404 path
  • Inline-edit GET/POST handlers for title and description (with discard-changes path)
  • Delete confirmation + delete handlers + cancel handler
  • All routes wired into the existing RequireAuth chi group in correct static-before-parametric order

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

@.planning/STATE.md @.planning/phases/03-tablos-crud/03-CONTEXT.md @.planning/phases/03-tablos-crud/03-RESEARCH.md @.planning/phases/03-tablos-crud/03-PATTERNS.md @.planning/phases/03-tablos-crud/03-UI-SPEC.md @.planning/phases/03-tablos-crud/03-01-SUMMARY.md @.planning/phases/03-tablos-crud/03-02-SUMMARY.md @backend/internal/web/handlers_tablos.go @backend/internal/web/router.go @backend/templates/tablos.templ @backend/internal/db/sqlc/tablos.sql.go @backend/internal/auth/middleware.go

Handler constructors (all return http.HandlerFunc; signature pattern matches Plan 02):

  • TabloDetailHandler(deps TablosDeps) → GET /tablos/{id}
  • TabloEditTitleHandler(deps TablosDeps) → GET /tablos/{id}/edit-title
  • TabloShowTitleHandler(deps TablosDeps) → GET /tablos/{id}/show-title
  • TabloEditDescHandler(deps TablosDeps) → GET /tablos/{id}/edit-desc
  • TabloShowDescHandler(deps TablosDeps) → GET /tablos/{id}/show-desc
  • TabloUpdateHandler(deps TablosDeps) → POST /tablos/{id}
  • TabloDeleteConfirmHandler(deps TablosDeps) → GET /tablos/{id}/delete-confirm
  • TabloDeleteCancelHandler(deps TablosDeps) → GET /tablos/{id}/delete-cancel
  • TabloDeleteHandler(deps TablosDeps) → POST /tablos/{id}/delete

Templates added to tablos.templ:

  • TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo)
  • TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string)
  • TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
  • TabloDescDisplay(tablo sqlc.Tablo, csrfToken string)
  • TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
  • TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) -- the .tablo-delete-zone with the Delete button (same shape as in TabloCard, factored out)
  • TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string)
  • TabloNotFoundPage(user *auth.User, csrfToken string) -- 404 body with copy from UI-SPEC

Form types (extend tablos_forms.go):

  • TabloUpdateErrors { Title, Description, General string }
Task 1: tablos.templ — detail page, edit fragments, delete fragments, 404 page (+ generate) backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go backend/templates/tablos.templ (current state from Plan 02) backend/templates/tablos_forms.go backend/templates/auth_form_errors.templ backend/templates/auth_signup.templ backend/internal/web/ui/button.templ backend/internal/web/ui/variants.go .planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contracts §3-§5, Component Inventory, Copywriting, HTMX Attribute Reference) .planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog blocks) .planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 2 fragment dispatch, Pattern 5 ownership 404) - TabloDetailPage wraps @Layout("Tablos — Xtablo", user, csrfToken) and inside the main container renders, in order: a back link to /, then a `.tablo-title-zone` div containing @TabloTitleDisplay, then a `.tablo-desc-zone` div containing @TabloDescDisplay, then a `.tablo-delete-zone` div containing @TabloDeleteButtonFragment. - TabloTitleDisplay renders the title as an `

` (the zone class on the SAME element so outerHTML swap replaces it cleanly) with hx-get="/tablos/{id}/edit-title", hx-target="closest .tablo-title-zone", hx-swap="outerHTML", role="button", aria-label="Edit title". Wraps with class "tablo-title-zone" — every Display/Edit fragment for the title shares this class so outerHTML round-trips work. - TabloTitleEditFragment renders a `` with hidden _method=PATCH (semantic clarity per UI-SPEC §4) NOT consumed by chi but harmless, @ui.CSRFField, a labelled title input prefilled with tablo.Title, a hidden description field carrying current description value, @FieldError(errs.Title), submit button "Save changes", discard button with hx-get="/tablos/{id}/show-title" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" Variant Neutral Tone Soft. - TabloDescDisplay analogous to TabloTitleDisplay but element is a `
` containing a `

` or empty-state hint "Add a description"; clicking fires hx-get="/tablos/{id}/edit-desc" hx-target="closest .tablo-desc-zone" hx-swap="outerHTML". - TabloDescEditFragment analogous to TabloTitleEditFragment but for the description textarea (3 rows) + hidden title field carrying current title; submit "Save changes" + discard "Discard changes" with hx-get="/tablos/{id}/show-desc". - TabloDeleteButtonFragment renders a `

` wrapping a Delete button (Variant Danger Tone Soft Size MD) with hx-get="/tablos/{id}/delete-confirm", hx-target="closest .tablo-delete-zone", hx-swap="outerHTML", aria-label="Delete tablo". - TabloDeleteConfirmFragment renders a `
` containing copy: "Delete tablo?" heading, "This cannot be undone." body, a "Yes, delete" submit button (Variant Danger Tone Solid Size MD, aria-label "Confirm delete tablo") inside a `` with @ui.CSRFField, and a "Keep tablo" cancel button (Variant Neutral Tone Soft, aria-label "Keep tablo") with hx-get="/tablos/{id}/delete-cancel" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML". - TabloNotFoundPage wraps @Layout("Not found", user, csrfToken) and renders heading "Not found" + body "This tablo doesn't exist or you don't have access." (per UI-SPEC Copywriting). user may be nil if called from a path before RequireAuth runs — accept nil and forward to Layout. - tablos_forms.go gains `type TabloUpdateErrors struct { Title, Description, General string }`. - Re-use the dashboard's TabloCard delete-zone shape so TabloDeleteButtonFragment is the canonical implementation; update TabloCard from Plan 02 (if necessary) to call `@TabloDeleteButtonFragment(t, csrfToken)` instead of inlining the delete button — this guarantees a single source of truth for the zone HTML. Edit backend/templates/tablos.templ to add the eight new templ components listed above. Place them after the Plan 02 components. Use exact copywriting from UI-SPEC. Use ui.Button with Variant/Tone/Size enums (ButtonVariantDanger, ButtonVariantNeutral already exist; ButtonToneSoft exists per variants.go).
Append `type TabloUpdateErrors struct { Title, Description, General string }` to backend/templates/tablos_forms.go.

If TabloCard in Plan 02 inlined the delete button HTML, refactor it to call `@TabloDeleteButtonFragment(t, csrfToken)` — DO NOT duplicate the zone markup.

Use class attribute `tablo-title-zone`, `tablo-desc-zone`, and `tablo-delete-zone` ON the outermost element of each respective Display/Edit fragment so HTMX's `hx-target="closest .tablo-X-zone"` + `hx-swap="outerHTML"` round-trips replace the zone wholesale.

Pitfall 5 reminder: no nested OOB swaps required in this plan, but the zone elements must NOT be wrapped in extra containers that the swap target would miss.

Run `just generate` to produce backend/templates/tablos_templ.go.
cd backend && just generate && grep -q "templ TabloDetailPage" templates/tablos.templ && grep -q "templ TabloTitleDisplay" templates/tablos.templ && grep -q "templ TabloTitleEditFragment" templates/tablos.templ && grep -q "templ TabloDescDisplay" templates/tablos.templ && grep -q "templ TabloDescEditFragment" templates/tablos.templ && grep -q "templ TabloDeleteButtonFragment" templates/tablos.templ && grep -q "templ TabloDeleteConfirmFragment" templates/tablos.templ && grep -q "templ TabloNotFoundPage" templates/tablos.templ && grep -q "Delete tablo?" templates/tablos.templ && grep -q "This cannot be undone." templates/tablos.templ && grep -q "Yes, delete" templates/tablos.templ && grep -q "Keep tablo" templates/tablos.templ && grep -q "Save changes" templates/tablos.templ && grep -q "Discard changes" templates/tablos.templ && grep -q "tablo-title-zone" templates/tablos.templ && grep -q "tablo-desc-zone" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "This tablo doesn't exist or you don't have access." templates/tablos.templ && grep -q "type TabloUpdateErrors" templates/tablos_forms.go && go build ./... - tablos.templ contains all 8 new templ component declarations exactly as named in the interfaces block. - File contains exact UI-SPEC copy: "Delete tablo?", "This cannot be undone.", "Yes, delete", "Keep tablo", "Save changes", "Discard changes", "Not found", "This tablo doesn't exist or you don't have access.". - Each of `tablo-title-zone`, `tablo-desc-zone`, `tablo-delete-zone` appears as a CSS class on at least TWO different templ components (Display + Edit, or Button + Confirm) — verified by `grep -c "class=\"tablo-title-zone\"" templates/tablos.templ` >= 2 (same for desc and delete zones). - aria-label values "Edit title", "Confirm delete tablo", "Keep tablo", "Delete tablo" all present. - hx-target uses `closest .tablo-{title,desc,delete}-zone` in every relevant fragment (not absolute selectors) so the same template works from card or detail-page contexts. - tablos_forms.go declares `type TabloUpdateErrors struct` with Title, Description, General string fields. - TabloCard delegates delete-button rendering to TabloDeleteButtonFragment (no inline duplication of the delete-zone HTML). - `just generate` produced an updated tablos_templ.go. - `go build ./...` exits 0. All Phase 3 templates declared, generated, and compile. Task 2: handlers_tablos.go — detail + edit + delete handlers + router wiring backend/internal/web/handlers_tablos.go, backend/internal/web/router.go backend/internal/web/handlers_tablos.go (current state from Plan 02) backend/internal/web/router.go (current state from Plan 02) backend/internal/web/handlers_auth.go (HTMX-aware redirect, fragment dispatch) backend/internal/auth/middleware.go (Authed, redirectTo) backend/internal/db/sqlc/tablos.sql.go (UpdateTablo signature) backend/internal/web/handlers_tablos_test.go (RED tests to turn green) .planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 3, 5, 6, 7, 8 + Pitfalls 1, 6, 7) .planning/phases/03-tablos-crud/03-PATTERNS.md (UUID extraction, ownership check, HTMX redirect) .planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §3-§5) Common pattern shared by every /tablos/{id}* handler: 1. `_, user, _ := auth.Authed(r.Context())` 2. Parse id: `tabloID, err := uuid.Parse(chi.URLParam(r, "id"))`. err != nil → http.NotFound(w, r); return. 3. Fetch: `tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)`. errors.Is(err, pgx.ErrNoRows) → http.NotFound. Other errors → 500. 4. Ownership: `if tablo.UserID != user.ID { http.NotFound(w, r); return }` (D-04: 404 not 403).
TabloDetailHandler (GET /tablos/{id}):
- After common steps, render TabloDetailPage(user, csrf.Token(r), tablo). Content-Type text/html; charset=utf-8.

TabloEditTitleHandler / TabloEditDescHandler (GET /tablos/{id}/edit-title|edit-desc):
- After common steps, render TabloTitleEditFragment / TabloDescEditFragment with empty TabloUpdateErrors.

TabloShowTitleHandler / TabloShowDescHandler (GET /tablos/{id}/show-title|show-desc):
- After common steps, render TabloTitleDisplay / TabloDescDisplay (used by discard-changes path).

TabloUpdateHandler (POST /tablos/{id}):
- After common steps, read r.PostFormValue("title") and r.PostFormValue("description"). Trim title.
- Validate: title required → "Title is required."; len(title) > 255 → "Title must be 255 characters or fewer.".
- On validation error: status 422 + render TabloTitleEditFragment (if r.PostFormValue("title") was the changed field) OR more simply re-render the detail page with errs surfaced. Pragmatic choice: render TabloTitleEditFragment with errs when HX-Request, else 422 + TabloDetailPage with errs.General. The test TestTabloUpdate only asserts happy path + DB update, so keep error path consistent with handlers_auth.go style.
- On success: call deps.Queries.UpdateTablo(ctx, UpdateTabloParams{ID: tabloID, Title: title, Description: pgtype.Text{Valid: description != "", String: description}}). Render TabloTitleDisplay (if title changed) OR TabloDescDisplay (if description changed) based on which field came through the form; simpler: detect which field was non-empty in the request and render the matching display fragment. Cleaner: since the title edit form submits BOTH title and description (description as hidden field) and vice versa, the handler always updates both and the caller's hx-target tells HTMX which zone to swap. Choose the cleaner approach — both fields always present; render the TabloTitleDisplay when r.PostFormValue("_zone") == "title" else TabloDescDisplay. To avoid the _zone hidden field complexity, render only TabloTitleDisplay for now and let TabloDescDisplay come on description-edit through a separate handler — but spec says one POST endpoint. Final decision: pass a `_zone` hidden input ("title" or "desc") in each edit fragment; handler reads it and renders the matching display. Non-HTMX path → 303 to /tablos/{id}.
- On success (HX-Request): 200 + display fragment in body. Non-HTMX: 303 to /tablos/{id}.

TabloDeleteConfirmHandler (GET /tablos/{id}/delete-confirm):
- After common steps, render TabloDeleteConfirmFragment(tablo, csrfToken).

TabloDeleteCancelHandler (GET /tablos/{id}/delete-cancel):
- After common steps, render TabloDeleteButtonFragment(tablo, csrfToken).

TabloDeleteHandler (POST /tablos/{id}/delete):
- After common steps, call deps.Queries.DeleteTablo(ctx, tabloID).
- HTMX path: 200 + HX-Redirect: / header. Non-HTMX path: 303 to /.
- Detail-page delete (HX-Request from detail page): same HX-Redirect: / behavior — the test TestTabloDelete checks for either deletion + redirect; document and pick one.

Router wiring (extend the protected group from Plan 02; MUST be added AFTER `r.Get("/tablos/new", ...)` and `r.Post("/tablos", ...)`):
- r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
- r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
- r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
- r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
- r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
- r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
- r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
- r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
- r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))

Pitfall 1: static `/tablos/new` already declared before parametric `/tablos/{id}` per Plan 02 ordering; verify by reading router.go before editing.
Append nine handler constructors to backend/internal/web/handlers_tablos.go per the behavior block. Each constructor returns http.HandlerFunc closing over deps. Factor the common preamble (Authed extract, uuid.Parse, GetTabloByID, ownership check) into a private helper `loadOwnedTablo(w, r, deps) (sqlc.Tablo, *auth.User, bool)` — returns false when any check fails (the helper has already written the 404/500 response). All nine specific-tablo handlers call this helper first.
Use imports: "github.com/go-chi/chi/v5", "github.com/google/uuid", "errors", "github.com/jackc/pgx/v5", "github.com/jackc/pgx/v5/pgtype". csrf token via `csrf.Token(r)`. PostFormValue for title/description (Pitfall 2). pgtype.Text{Valid: s != "", String: s} for nullable description.

For TabloUpdateHandler: emit `<input type="hidden" name="_zone" value="title">` or `..."desc"` from the respective edit fragment template (Task 1 must already do this — if not, fix Task 1's templates to add the hidden _zone field). Handler reads `zone := r.PostFormValue("_zone")` and renders TabloTitleDisplay if zone == "title", TabloDescDisplay if zone == "desc", else default to TabloTitleDisplay. Non-HTMX path: 303 redirect to /tablos/{id} (full page reload renders the new state).

For TabloDeleteHandler HTMX response: `w.Header().Set("HX-Redirect", "/"); w.WriteHeader(http.StatusOK)`. Non-HTMX: `http.Redirect(w, r, "/", http.StatusSeeOther)`.

Edit backend/internal/web/router.go: append the nine new routes inside the protected group AFTER the existing `r.Post("/tablos", ...)` line and BEFORE the closing `})`. Order: `r.Get("/tablos/{id}", ...)`, `r.Post("/tablos/{id}", ...)`, then the six sub-route GETs, then `r.Post("/tablos/{id}/delete", ...)`.

After edits run `just test`. All 10 TABLO tests must be green. Phase 1/2 tests must remain green.
cd backend && just generate && grep -q "func TabloDetailHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloUpdateHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteConfirmHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteCancelHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteHandler" internal/web/handlers_tablos.go && grep -q "uuid.Parse(chi.URLParam" internal/web/handlers_tablos.go && grep -q "tablo.UserID != user.ID" internal/web/handlers_tablos.go && grep -q "http.NotFound" internal/web/handlers_tablos.go && grep -q "HX-Redirect" internal/web/handlers_tablos.go && grep -q "deps.Queries.UpdateTablo" internal/web/handlers_tablos.go && grep -q "deps.Queries.DeleteTablo" internal/web/handlers_tablos.go && grep -q "r.Get(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Get(\"/tablos/{id}/edit-title\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}/delete\"" internal/web/router.go && awk '/r.Get\("\/tablos\/new"/ {newln=NR} /r.Get\("\/tablos\/\{id\}"/ {idln=NR} END {exit !(newln>0 && idln>0 && newln - handlers_tablos.go declares all nine new handler functions (signatures match the interfaces block). - handlers_tablos.go contains a single ownership-and-load helper used by all nine new handlers (grep "loadOwnedTablo" or equivalent appears >=2 occurrences: the declaration + at least one caller). - Every specific-tablo handler returns 404 on (a) uuid.Parse error, (b) pgx.ErrNoRows, (c) UserID mismatch — verified by passing TestTabloDetail_NonOwner and TestTabloDetail_InvalidID. - TabloUpdateHandler calls deps.Queries.UpdateTablo with the parsed ID and trimmed title; pgtype.Text used for description. - TabloDeleteHandler calls deps.Queries.DeleteTablo and sets HX-Redirect: / on HTMX path, 303 to / on non-HTMX path. - router.go contains the nine new routes inside the existing RequireAuth group. The line declaring `r.Get("/tablos/new", ...)` appears BEFORE the line declaring `r.Get("/tablos/{id}", ...)` (Pitfall 1) — verified by the awk check in the verify block. - `go build ./...` exits 0. - `go test ./internal/web/... -run TestTablo -count=1` exits 0 — all 10 named TABLO tests pass. - Phase 1/2 regression: `go test ./internal/web/...` exits 0 across all tests (no broken auth/signup/csrf tests). Detail + edit + delete slice green; all 10 TABLO tests pass; static-before-parametric order verified. Task 3: Human verify detail + edit + delete + ownership 404 Tablo detail page with inline edit for title and description, and inline-confirmation delete. Non-owner and bad-UUID requests return 404. HTMX paths swap fragments without full reloads; non-HTMX paths POST and redirect cleanly. 1. cd backend && just dev. 2. Log in as user A. Create two tablos. 3. Click "View" on a tablo card → detail page renders with title and description. 4. Click the title → input appears in place; change text; click "Save changes" — display swaps back to the new title without full reload. Click "Discard changes" instead to confirm it restores the original. 5. Repeat for description. 6. From the detail page, click "Delete" → inline confirmation "Delete tablo? This cannot be undone." [Yes, delete] [Keep tablo] appears in place. 7. Click "Keep tablo" → confirmation collapses, Delete button restored. 8. Click "Delete" → "Yes, delete" → page navigates to / (HX-Redirect); tablo is gone from list. 9. Repeat delete from dashboard card — confirmation appears inside the card's delete-zone, "Yes, delete" removes the card from the list. 10. Ownership test: open a private window, sign up as user B. Try GET /tablos/{user-A-tablo-uuid} → 404 page with copy "This tablo doesn't exist or you don't have access.". 11. Bad UUID test: GET /tablos/not-a-uuid → 404. 12. CSRF test: view-source the detail page; confirm `_csrf` hidden input is rendered in every form (edit-title form, edit-desc form, delete-confirm form). 13. Non-JS fallback: disable JavaScript; from detail page submit edit form via the actual Submit button — page reloads to /tablos/{id} with updated content. Submit delete via the actual button — redirects to /. Type "approved" if all 13 checks pass, or describe issues.

<threat_model>

Trust Boundaries

Boundary Description
Browser → /tablos/{id}* URL parameter (UUID) is untrusted; must be parsed and ownership-checked on every request.
Browser → POST /tablos/{id} Untrusted form input (title, description, _zone, _csrf).
Browser → POST /tablos/{id}/delete Authenticated state-changing request guarded by CSRF token.
Handler → Postgres Parameterised via sqlc UpdateTabloParams / DeleteTablo($1).

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-03-03-01 Elevation of privilege Cross-tenant tablo access mitigate loadOwnedTablo helper enforces tablo.UserID == user.ID; non-owner gets http.NotFound (D-04: 404 not 403 to avoid existence leak). Verified by TestTabloDetail_NonOwner.
T-03-03-02 Information disclosure UUID enumeration mitigate UUIDs are gen_random_uuid (crypto-random) + 404 response for non-owner masks existence.
T-03-03-03 Tampering UUID injection / path traversal via {id} mitigate uuid.Parse rejects non-UUID strings → http.NotFound before any DB query. Verified by TestTabloDetail_InvalidID.
T-03-03-04 Tampering CSRF on POST /tablos/{id} and /tablos/{id}/delete mitigate gorilla/csrf middleware validates _csrf field; @ui.CSRFField rendered inside every form template (edit-title, edit-desc, delete-confirm).
T-03-03-05 DoS over-long title on edit mitigate TabloUpdateHandler validates len(title) <= 255 with explicit 422 + field error before DB write.
T-03-03-06 XSS reflected user content in title/description on detail page and edit fragments mitigate templ auto-escapes { tablo.Title }, { tablo.Description.String }. No templ.Raw. Inline color style attribute also escaped.
T-03-03-07 Repudiation edit/delete not logged accept v1 scope — created_at/updated_at on the row; no audit table planned for v1.
T-03-03-08 Tampering _zone hidden field abuse accept _zone selects which display fragment to render; misuse only affects the response body shape, not DB state — DB UPDATE always uses authenticated user's row regardless of _zone value.
T-03-03-09 Spoofing Delete bypass via direct POST mitigate RequireAuth middleware + loadOwnedTablo helper run on /tablos/{id}/delete; unauthed → /login, non-owner → 404. CSRF middleware also blocks cross-origin POSTs.
</threat_model>
- All 10 TABLO tests pass: `go test ./internal/web/... -run TestTablo -count=1` exits 0. - Phase 1/2 regression suite still green: `go test ./internal/web/...` exits 0. - Manual browser verification (Task 3 checkpoint) passes all 13 sub-checks, including ownership 404 + bad-UUID 404 + non-JS fallbacks. - Static-before-parametric route ordering verified in router.go (Pitfall 1). - `grep -c "http.NotFound" backend/internal/web/handlers_tablos.go` >= 3 (uuid parse failure + pgx.ErrNoRows + ownership mismatch).

<success_criteria>

  1. TABLO-03: GET /tablos/{id} renders detail page for owner; non-owner and malformed UUID get 404.
  2. TABLO-04: Inline edit of title and description updates the row, refreshes the affected fragment, and sets updated_at = now() (Pitfall 7).
  3. TABLO-05: Inline confirmation gates delete; confirming hard-deletes the row and redirects to /; cancel restores the original button.
  4. TABLO-06: Every mutating route works under both HTMX and non-HTMX clients (303 fallback verified manually).
  5. All ten TABLO tests in handlers_tablos_test.go are green.
  6. No Phase 1/2 regressions. </success_criteria>
After completion, create `.planning/phases/03-tablos-crud/03-03-SUMMARY.md` documenting: nine new handler functions and the loadOwnedTablo helper, eight new templ components, nine new router lines (in declared order), final TABLO test status (10/10 green), and notes on the _zone hidden-field approach used for the unified POST /tablos/{id} update endpoint.