338 lines
29 KiB
Markdown
338 lines
29 KiB
Markdown
|
|
---
|
||
|
|
phase: 03-tablos-crud
|
||
|
|
plan: 03
|
||
|
|
type: execute
|
||
|
|
wave: 3
|
||
|
|
depends_on: [01, 02]
|
||
|
|
files_modified:
|
||
|
|
- backend/internal/web/handlers_tablos.go
|
||
|
|
- backend/templates/tablos.templ
|
||
|
|
- backend/templates/tablos_templ.go
|
||
|
|
- backend/internal/web/router.go
|
||
|
|
autonomous: false
|
||
|
|
requirements:
|
||
|
|
- TABLO-03
|
||
|
|
- TABLO-04
|
||
|
|
- TABLO-05
|
||
|
|
- TABLO-06
|
||
|
|
tags:
|
||
|
|
- go
|
||
|
|
- htmx
|
||
|
|
- templ
|
||
|
|
- chi
|
||
|
|
- csrf
|
||
|
|
- ownership
|
||
|
|
|
||
|
|
must_haves:
|
||
|
|
truths:
|
||
|
|
- "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)"
|
||
|
|
artifacts:
|
||
|
|
- path: "backend/internal/web/handlers_tablos.go"
|
||
|
|
provides: "TabloDetailHandler, TabloEditTitleHandler, TabloEditDescHandler, TabloShowTitleHandler, TabloShowDescHandler, TabloUpdateHandler, TabloDeleteConfirmHandler, TabloDeleteCancelHandler, TabloDeleteHandler"
|
||
|
|
contains: "TabloDeleteHandler"
|
||
|
|
- path: "backend/templates/tablos.templ"
|
||
|
|
provides: "TabloDetailPage, TabloTitleDisplay, TabloTitleEditFragment, TabloDescDisplay, TabloDescEditFragment, TabloDeleteButtonFragment, TabloDeleteConfirmFragment, TabloNotFoundPage"
|
||
|
|
contains: "TabloDeleteConfirmFragment"
|
||
|
|
- path: "backend/internal/web/router.go"
|
||
|
|
provides: "All remaining /tablos/{id}* route registrations in correct order"
|
||
|
|
contains: "TabloDeleteHandler"
|
||
|
|
key_links:
|
||
|
|
- from: "TabloDetailHandler"
|
||
|
|
to: "ownership check"
|
||
|
|
via: "tablo.UserID != user.ID → http.NotFound"
|
||
|
|
pattern: "http\\.NotFound"
|
||
|
|
- from: "TabloUpdateHandler"
|
||
|
|
to: "Queries.UpdateTablo"
|
||
|
|
via: "sqlc binding with updated_at = now()"
|
||
|
|
pattern: "UpdateTablo"
|
||
|
|
- from: "TabloDeleteHandler"
|
||
|
|
to: "Queries.DeleteTablo"
|
||
|
|
via: "sqlc binding"
|
||
|
|
pattern: "DeleteTablo"
|
||
|
|
---
|
||
|
|
|
||
|
|
<objective>
|
||
|
|
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
|
||
|
|
</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>
|
||
|
|
@.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
|
||
|
|
|
||
|
|
<interfaces>
|
||
|
|
<!-- Handler & template contracts introduced by this plan -->
|
||
|
|
|
||
|
|
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 }
|
||
|
|
</interfaces>
|
||
|
|
</context>
|
||
|
|
|
||
|
|
<tasks>
|
||
|
|
|
||
|
|
<task type="auto" tdd="true">
|
||
|
|
<name>Task 1: tablos.templ — detail page, edit fragments, delete fragments, 404 page (+ generate)</name>
|
||
|
|
<files>backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go</files>
|
||
|
|
<read_first>
|
||
|
|
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)
|
||
|
|
</read_first>
|
||
|
|
<behavior>
|
||
|
|
- 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 `<h1 class="text-xl font-semibold leading-snug tablo-title-zone">` (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 `<form class="tablo-title-zone" hx-post="/tablos/{id}" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" method="POST" action="/tablos/{id}">` 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 `<div class="tablo-desc-zone">` containing a `<p>` 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 `<div class="tablo-delete-zone">` 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 `<div class="tablo-delete-zone">` 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 `<form method="POST" action="/tablos/{id}/delete" hx-post="/tablos/{id}/delete" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML">` 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.
|
||
|
|
</behavior>
|
||
|
|
<action>
|
||
|
|
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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>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 ./...</automated>
|
||
|
|
</verify>
|
||
|
|
<acceptance_criteria>
|
||
|
|
- 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.
|
||
|
|
</acceptance_criteria>
|
||
|
|
<done>All Phase 3 templates declared, generated, and compile.</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
<task type="auto" tdd="true">
|
||
|
|
<name>Task 2: handlers_tablos.go — detail + edit + delete handlers + router wiring</name>
|
||
|
|
<files>backend/internal/web/handlers_tablos.go, backend/internal/web/router.go</files>
|
||
|
|
<read_first>
|
||
|
|
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)
|
||
|
|
</read_first>
|
||
|
|
<behavior>
|
||
|
|
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.
|
||
|
|
</behavior>
|
||
|
|
<action>
|
||
|
|
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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>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<idln)}' internal/web/router.go && go build ./... && go test ./internal/web/... -run TestTablo -count=1</automated>
|
||
|
|
</verify>
|
||
|
|
<acceptance_criteria>
|
||
|
|
- 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).
|
||
|
|
</acceptance_criteria>
|
||
|
|
<done>Detail + edit + delete slice green; all 10 TABLO tests pass; static-before-parametric order verified.</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
||
|
|
<name>Task 3: Human verify detail + edit + delete + ownership 404</name>
|
||
|
|
<what-built>
|
||
|
|
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.
|
||
|
|
</what-built>
|
||
|
|
<how-to-verify>
|
||
|
|
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 /.
|
||
|
|
</how-to-verify>
|
||
|
|
<resume-signal>Type "approved" if all 13 checks pass, or describe issues.</resume-signal>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
</tasks>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<verification>
|
||
|
|
- 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).
|
||
|
|
</verification>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<output>
|
||
|
|
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.
|
||
|
|
</output>
|