From f53b54637b95bb275ca708ea4afc0eb620025dfe Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 00:08:08 +0200 Subject: [PATCH] =?UTF-8?q?docs(03):=20plan=20phase=203=20=E2=80=94=20Tabl?= =?UTF-8?q?os=20CRUD=20(3=20plans,=203=20waves)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .planning/ROADMAP.md | 7 + .planning/STATE.md | 10 +- .planning/phases/03-tablos-crud/03-01-PLAN.md | 313 ++++++++ .planning/phases/03-tablos-crud/03-02-PLAN.md | 324 ++++++++ .planning/phases/03-tablos-crud/03-03-PLAN.md | 337 +++++++++ .../phases/03-tablos-crud/03-PATTERNS.md | 702 ++++++++++++++++++ .../phases/03-tablos-crud/03-RESEARCH.md | 8 +- .../phases/03-tablos-crud/03-VALIDATION.md | 85 +++ 8 files changed, 1777 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/03-tablos-crud/03-01-PLAN.md create mode 100644 .planning/phases/03-tablos-crud/03-02-PLAN.md create mode 100644 .planning/phases/03-tablos-crud/03-03-PLAN.md create mode 100644 .planning/phases/03-tablos-crud/03-PATTERNS.md create mode 100644 .planning/phases/03-tablos-crud/03-VALIDATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e54be6f..15dc897 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -82,6 +82,12 @@ Plans: **User-in-loop:** Approve the `tablos` table schema (ownership model, soft-delete vs hard-delete, slug strategy). +**Plans:** 3 plans +Plans: +- [ ] 03-01-PLAN.md — Wave 0: migration 0003_tablos + sqlc queries + handlers_tablos_test.go RED scaffold + button.css danger/neutral variants +- [ ] 03-02-PLAN.md — Vertical slice 1: dashboard list + inline-form create (HTMX OOB swap; TABLO-01, TABLO-02, TABLO-06) +- [ ] 03-03-PLAN.md — Vertical slice 2: detail + inline edit (title/description) + inline-confirmation delete (TABLO-03, TABLO-04, TABLO-05, TABLO-06) + ### Phase 4: Tasks (Kanban) **Goal:** Inside a tablo, a user can run a kanban board — create, edit, move, reorder, and delete tasks across columns. **Mode:** mvp @@ -154,3 +160,4 @@ Plans: --- *Roadmap created: 2026-05-14* +*Phase 3 plans added: 2026-05-15* diff --git a/.planning/STATE.md b/.planning/STATE.md index fa36601..343c9f5 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: in_progress -last_updated: "2026-05-14T21:36:00.262Z" +last_updated: "2026-05-15T00:00:00.000Z" progress: total_phases: 7 completed_phases: 2 - total_plans: 11 + total_plans: 14 completed_plans: 11 - percent: 100 + percent: 79 --- # STATE @@ -31,7 +31,7 @@ See: `.planning/PROJECT.md` (updated 2026-05-14) |---|-------|--------| | 1 | Foundation | ✓ Complete | | 2 | Authentication | ✓ Complete — VERIFIED PASS (2026-05-14) | -| 3 | Tablos CRUD | ○ Pending | +| 3 | Tablos CRUD | ◆ Ready to execute — 3 plans (3 waves) | | 4 | Tasks (Kanban) | ○ Pending | | 5 | Files | ○ Pending | | 6 | Background Worker | ○ Pending | @@ -39,7 +39,7 @@ See: `.planning/PROJECT.md` (updated 2026-05-14) ## Active Phase -**Phase 2: Authentication** — Verified PASS. All 7/7 plans complete, all AUTH-01..07 requirements satisfied. Phase 3: Tablos CRUD is next. +**Phase 3: Tablos CRUD** — Planned. 3 plans in 3 sequential waves. Ready to execute. Requirements: TABLO-01..06 covered. Planned 2026-05-15. ## Verification Record diff --git a/.planning/phases/03-tablos-crud/03-01-PLAN.md b/.planning/phases/03-tablos-crud/03-01-PLAN.md new file mode 100644 index 0000000..55471e0 --- /dev/null +++ b/.planning/phases/03-tablos-crud/03-01-PLAN.md @@ -0,0 +1,313 @@ +--- +phase: 03-tablos-crud +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/migrations/0003_tablos.sql + - backend/internal/db/queries/tablos.sql + - backend/internal/db/sqlc/tablos.sql.go + - backend/internal/db/sqlc/models.go + - backend/internal/web/handlers_tablos_test.go + - backend/internal/web/ui/button.css +autonomous: true +requirements: + - TABLO-01 + - TABLO-02 + - TABLO-03 + - TABLO-04 + - TABLO-05 + - TABLO-06 +tags: + - go + - postgres + - sqlc + - goose + - integration-tests + +must_haves: + truths: + - "Migration 0003_tablos creates tablos table with all columns from D-02" + - "sqlc generates Tablo struct with pgtype.Text for nullable description and color" + - "handlers_tablos_test.go compiles and runs (all tests fail/skip because handlers do not exist yet)" + - "Button CSS gains .ui-button-solid-danger-md and .ui-button-soft-neutral-md rules" + artifacts: + - path: "backend/migrations/0003_tablos.sql" + provides: "tablos table + tablos_user_id_idx" + contains: "CREATE TABLE tablos" + - path: "backend/internal/db/queries/tablos.sql" + provides: "5 sqlc queries: ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo" + contains: "name: ListTablosByUser" + - path: "backend/internal/db/sqlc/tablos.sql.go" + provides: "Generated sqlc query bindings" + contains: "ListTablosByUser" + - path: "backend/internal/web/handlers_tablos_test.go" + provides: "Integration test scaffold for all TABLO-01..06 paths" + contains: "func TestTabloList" + - path: "backend/internal/web/ui/button.css" + provides: "Danger and neutral-soft button variants" + contains: "ui-button-solid-danger-md" + key_links: + - from: "backend/migrations/0003_tablos.sql" + to: "users(id)" + via: "REFERENCES users(id) ON DELETE CASCADE" + pattern: "REFERENCES users\\(id\\) ON DELETE CASCADE" + - from: "backend/internal/db/queries/tablos.sql" + to: "backend/internal/db/sqlc/tablos.sql.go" + via: "sqlc generate" + pattern: "ListTablosByUser" +--- + + +Lay the data + test foundation for Phase 3 Tablos CRUD: the goose migration, sqlc query file, generated bindings, and the Wave 0 integration test scaffold that downstream plans will turn green. Add the two new button CSS variants so subsequent template work can render the delete/cancel buttons without inline styles. + +Purpose: Establish the schema, type-safe DB layer, and failing-test baseline before any handler code is written. Plans 02 and 03 turn this red into green. + +Output: +- 0003_tablos.sql migration +- tablos.sql query file + generated tablos.sql.go + extended models.go +- handlers_tablos_test.go covering all TABLO-01..06 paths (RED, compiles, fails because handlers/routes do not yet exist) +- button.css with danger + neutral-soft variants + + +## Phase Goal + +The ROADMAP `**Goal:**` line is task-shaped ("A logged-in user can list, create, view, edit, and delete..."), not a user story. Recording derived user story for MVP_MODE; if mismatch matters, run `/gsd mvp-phase 03` to formalise. + +**As a** signed-in Xtablo user, **I want to** create, view, edit, and delete my own tablos through HTMX-driven flows, **so that** I can organise my work without leaving the dashboard or seeing full-page reloads. + + +@/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 + + + +@.planning/STATE.md +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.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-VALIDATION.md +@backend/migrations/0002_auth.sql +@backend/internal/db/queries/users.sql +@backend/internal/db/sqlc/models.go +@backend/internal/web/handlers_auth_test.go +@backend/internal/web/testdb_test.go +@backend/internal/web/ui/button.css +@backend/internal/web/ui/variants.go + + + + +Expected sqlc-generated Tablo struct (in backend/internal/db/sqlc/models.go): +- ID uuid.UUID +- UserID uuid.UUID +- Title string +- Description pgtype.Text (Valid + String) +- Color pgtype.Text +- CreatedAt pgtype.Timestamptz +- UpdatedAt pgtype.Timestamptz + +Expected sqlc-generated Queries methods (backend/internal/db/sqlc/tablos.sql.go): +- ListTablosByUser(ctx, userID uuid.UUID) ([]Tablo, error) +- GetTabloByID(ctx, id uuid.UUID) (Tablo, error) +- InsertTablo(ctx, InsertTabloParams) (Tablo, error) -- params: UserID, Title, Description (pgtype.Text), Color (pgtype.Text) +- UpdateTablo(ctx, UpdateTabloParams) (Tablo, error) -- params: ID, Title, Description; sets updated_at = now() +- DeleteTablo(ctx, id uuid.UUID) error + +Existing reusable infra (do NOT reimplement): +- backend/internal/web/testdb_test.go: setupTestDB(t) — boots a per-test schema using goose.Up against `backend/migrations`, returns *pgxpool.Pool. Phase 3 migrations are picked up automatically by goose. +- backend/internal/auth: RequireAuth middleware, Authed(ctx), Store +- backend/internal/web/handlers_auth_test.go: pattern for newAuthedRequest helper, cookie+session+csrf wiring +- backend/internal/web/ui/variants.go already declares ButtonVariantDanger, ButtonVariantNeutral, ButtonToneSoft — only the CSS classes are missing. + + + + + + + Task 1: Migration 0003_tablos.sql + sqlc query file + regenerate sqlc + backend/migrations/0003_tablos.sql, backend/internal/db/queries/tablos.sql, backend/internal/db/sqlc/tablos.sql.go, backend/internal/db/sqlc/models.go + + backend/migrations/0002_auth.sql + backend/internal/db/queries/users.sql + backend/internal/db/sqlc/models.go + backend/internal/db/sqlc/users.sql.go + backend/sqlc.yaml + backend/justfile + .planning/phases/03-tablos-crud/03-CONTEXT.md (D-01, D-02, D-03) + .planning/phases/03-tablos-crud/03-RESEARCH.md (Migration + sqlc Queries sections) + .planning/phases/03-tablos-crud/03-PATTERNS.md (analog migration + queries blocks) + + + - Migration up creates tablos table with columns: id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, title text NOT NULL, description text NULL, color text NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(). + - Index tablos_user_id_idx on tablos(user_id). + - Migration down: DROP TABLE IF EXISTS tablos. + - sqlc generates Tablo struct matching the interfaces block above (pgtype.Text for description/color, pgtype.Timestamptz for created_at/updated_at). + - UpdateTablo SQL must SET updated_at = now() explicitly (Pitfall 7). + + + Create backend/migrations/0003_tablos.sql following the 0002_auth.sql header style ("-- migrations/0003_tablos.sql\n-- Phase 3: Tablos CRUD") with goose Up/Down sections. Schema per D-02 (CONTEXT.md): id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, title text NOT NULL, description text, color text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(); CREATE INDEX tablos_user_id_idx ON tablos(user_id); Down drops tablos. + + Create backend/internal/db/queries/tablos.sql with exactly five named queries matching the RESEARCH.md "sqlc Queries" code block: + - ListTablosByUser :many (WHERE user_id = $1 ORDER BY created_at DESC) + - GetTabloByID :one (WHERE id = $1) + - InsertTablo :one (INSERT user_id,title,description,color RETURNING all columns) + - UpdateTablo :one (SET title=$2, description=$3, updated_at=now() WHERE id=$1 RETURNING all columns) — do NOT include color (per RESEARCH "color not editable in Phase 3") + - DeleteTablo :exec (DELETE FROM tablos WHERE id = $1) + + Use explicit column lists in SELECTs (no SELECT *), positional $N parameters, and the same -- name: header style as users.sql. + + Run `just generate` (or `just sqlc` if the justfile separates them) to produce backend/internal/db/sqlc/tablos.sql.go and update backend/internal/db/sqlc/models.go. Do not hand-edit the generated files. + + + cd backend && just generate && grep -q "CREATE TABLE tablos" migrations/0003_tablos.sql && grep -q "tablos_user_id_idx" migrations/0003_tablos.sql && grep -q "name: ListTablosByUser" internal/db/queries/tablos.sql && grep -q "name: UpdateTablo" internal/db/queries/tablos.sql && grep -q "updated_at = now()" internal/db/queries/tablos.sql && grep -q "func (q \\*Queries) ListTablosByUser" internal/db/sqlc/tablos.sql.go && grep -q "type Tablo struct" internal/db/sqlc/models.go && go build ./... + + + - File backend/migrations/0003_tablos.sql exists and contains "CREATE TABLE tablos", "REFERENCES users(id) ON DELETE CASCADE", "tablos_user_id_idx", "-- +goose Up", "-- +goose Down". + - File backend/internal/db/queries/tablos.sql exists and contains exactly the 5 query names (ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo). + - tablos.sql contains "ORDER BY created_at DESC" (D-03) and "updated_at = now()" (Pitfall 7) in UpdateTablo. + - tablos.sql UpdateTablo does NOT include color (Phase 3 scope per RESEARCH §sqlc Queries note). + - backend/internal/db/sqlc/tablos.sql.go exists with exported methods ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo on *Queries. + - backend/internal/db/sqlc/models.go contains `type Tablo struct` with Description and Color fields typed as pgtype.Text. + - `go build ./...` from backend/ exits 0. + + Migration committed, query file committed, sqlc-generated files committed, build green. + + + + Task 2: handlers_tablos_test.go — RED integration test scaffold for TABLO-01..06 + backend/internal/web/handlers_tablos_test.go + + backend/internal/web/handlers_auth_test.go + backend/internal/web/testdb_test.go + backend/internal/web/router.go + backend/internal/web/csrf_test.go + backend/internal/db/sqlc/tablos.sql.go (from Task 1) + backend/internal/db/sqlc/models.go (Tablo struct shape) + .planning/phases/03-tablos-crud/03-VALIDATION.md (per-task verification map) + .planning/phases/03-tablos-crud/03-RESEARCH.md (Phase Requirements → Test Map) + + + File must compile and `go test` must execute every named test. All tests are expected to FAIL or panic for now (handlers/routes do not exist) — that is the RED state Plans 02 and 03 will fix. Each test must exist as a real Go function (no t.Skip stubs) so the failing assertions document the contract. + + Tests required (function names exact, per 03-VALIDATION.md): + - TestTabloList — authed GET / shows current user's tablos, newest-first; only the calling user's rows present. + - TestTabloList_Empty — authed GET / with no tablos renders empty-state copy "No tablos yet" + "Create your first tablo to get started.". + - TestTabloCreate — authed POST /tablos with HX-Request:true and valid title/description/color: returns 200, body contains the new title; HX-Retarget header == "#tablos-list", HX-Reswap header == "afterbegin"; response body contains an OOB element `id="create-form-slot"` with hx-swap-oob="true"; DB row exists with user_id = caller. Sub-asserts cover TABLO-06: non-HTMX POST returns 303 to "/". + - TestTabloCreate_Validation — POST /tablos with empty title returns 422; response body contains "Title is required.". + - TestTabloDetail_Owner — GET /tablos/{id} as owner returns 200 with title rendered. + - TestTabloDetail_NonOwner — GET /tablos/{id} as a different authed user returns 404 (not 403). + - TestTabloDetail_InvalidID — GET /tablos/not-a-uuid returns 404. + - TestTabloUpdate — authed POST /tablos/{id} with new title/description: returns 200, body contains updated title; DB row title/updated_at refreshed. + - TestTabloDeleteConfirm — GET /tablos/{id}/delete-confirm returns 200 with body containing "Delete tablo?" and "Yes, delete". + - TestTabloDelete — authed POST /tablos/{id}/delete: DB row deleted; HTMX request gets 200 + HX-Redirect:/ header (or HX-Retarget #tablos-list on dashboard delete); non-HTMX gets 303 to /. + + + Create backend/internal/web/handlers_tablos_test.go in package web. Reuse setupTestDB (from testdb_test.go) and the CSRF/cookie helpers exactly as handlers_auth_test.go does — use a duplicated helper if needed (per STATE.md decision: "setupTestDB duplicated into web package test file"). Drive requests through NewRouter so the full middleware chain (RequestID, ResolveSession, csrf.Protect, RequireAuth) runs. + + For each test: + 1. Call setupTestDB(t) to get *pgxpool.Pool. Build *sqlc.Queries. + 2. Insert two test users via Queries.InsertUser (reusing handlers_auth_test.go pattern). + 3. Build TablosDeps{Queries: q} and pass into web.NewRouter using the NEW signature: NewRouter(pinger, "./static", AuthDeps{...}, web.TablosDeps{Queries: q}, csrfKey, "test", "localhost"). Plans 02/03 must add TablosDeps to web package; if symbol does not yet exist when test compiles, expect a compile error — that is part of the RED state. To keep the test FILE compilable now while still being RED at runtime, declare a local stub: `var _ = web.TablosDeps{}` referencing the type Plan 02 introduces. If that prevents compilation, gate the test scaffold with the same TablosDeps stub introduced in Plan 02 Task 0 (interface skeleton). + 4. Acquire a session by POSTing /signup with HX-Request:false (same flow as handlers_auth_test.go), capture cookie + CSRF token via subsequent GET /. + 5. Issue the test's HTTP request via httptest.NewRecorder + router.ServeHTTP. + 6. Assert status, headers (HX-Retarget, HX-Reswap, HX-Redirect), and body substrings per the behavior block. + 7. Verify DB state by calling Queries.GetTabloByID / ListTablosByUser as appropriate. + + Use t.Run subtests where one Test* name covers multiple assertions (e.g. TestTabloCreate covers HTMX-path + non-HTMX-path). + + DO NOT implement handlers in this task — only the test scaffold. Tests are expected to fail until Plans 02 and 03 land. + + + cd backend && just generate && go test ./internal/web/... -run TestTablo -count=1 2>&1 | tee /tmp/test_out.log; grep -E "TestTabloList|TestTabloCreate|TestTabloDetail|TestTabloUpdate|TestTabloDelete" /tmp/test_out.log | grep -cE "(FAIL|RUN)" | xargs -I{} test {} -ge 10 && echo "scaffold present" + + + - File backend/internal/web/handlers_tablos_test.go exists in package web. + - grep -c '^func Test' backend/internal/web/handlers_tablos_test.go returns >= 10 (covers the 10 named tests above). + - Each of these substrings appears in the file: "TestTabloList(", "TestTabloList_Empty(", "TestTabloCreate(", "TestTabloCreate_Validation(", "TestTabloDetail_Owner(", "TestTabloDetail_NonOwner(", "TestTabloDetail_InvalidID(", "TestTabloUpdate(", "TestTabloDeleteConfirm(", "TestTabloDelete(". + - File references HX-Retarget and HX-Reswap as expected response headers in TestTabloCreate. + - File references http.StatusSeeOther (303) and HX-Redirect for the post-delete navigation assertion. + - File contains an ownership-cross-user assertion (creates user A's tablo, requests it as user B, expects 404) in TestTabloDetail_NonOwner. + - Test file's intended state is RED — Plan 01 acceptance is that the file compiles after Plan 02 introduces the TablosDeps stub. To unblock Plan 01 alone, if web.TablosDeps does not yet exist, this task additionally creates a one-line stub `type TablosDeps struct { Queries *sqlc.Queries }` at the top of handlers_tablos.go (new empty file) so the test file compiles. + - `go vet ./internal/web/...` exits 0. + + Test scaffold compiles; running `go test ./internal/web/... -run TestTablo` lists all 10 tests as FAIL (no implementation yet). + + + + Task 3: Add ui-button-solid-danger-md and ui-button-soft-neutral-md CSS rules + backend/internal/web/ui/button.css + + backend/internal/web/ui/button.css + backend/internal/web/ui/variants.go + .planning/phases/03-tablos-crud/03-UI-SPEC.md (New Button Variants Required + Color sections) + .planning/phases/03-tablos-crud/03-PATTERNS.md (button.css analog block) + + + Append two new top-level rule sets to backend/internal/web/ui/button.css following the existing convention (NO CSS nesting — all pseudo-class selectors as separate top-level rules; class name format `.ui-button-{tone}-{variant}-{size}`). + + Class `.ui-button-solid-danger-md`: background-color #b91c1c (red-700), color #ffffff, padding 0.5rem 1rem, border-radius 0.375rem, font-size 1rem, font-weight 600, display inline-flex, align-items center, min-height 44px (WCAG 2.5.5 per UI-SPEC). Hover rule: background-color #991b1b. Focus-visible rule: outline 2px solid #b91c1c, outline-offset 2px. + + Class `.ui-button-soft-neutral-md`: background-color #f1f5f9 (slate-100), color #334155 (slate-700), padding 0.5rem 1rem, border-radius 0.375rem, font-size 1rem, font-weight 400, border 1px solid #e2e8f0, display inline-flex, align-items center, min-height 44px. Hover rule: background-color #e2e8f0. Focus-visible rule: outline 2px solid #64748b, outline-offset 2px. + + Run `just generate` to rebuild static/tailwind.css (Pitfall 4: imported CSS passes through). + + + cd backend && grep -c "^\\.ui-button-solid-danger-md" internal/web/ui/button.css | xargs -I{} test {} -ge 1 && grep -c "^\\.ui-button-soft-neutral-md" internal/web/ui/button.css | xargs -I{} test {} -ge 1 && grep -q "#b91c1c" internal/web/ui/button.css && grep -q "#f1f5f9" internal/web/ui/button.css && grep -q "min-height: 44px" internal/web/ui/button.css && just generate && grep -q "ui-button-solid-danger-md" static/tailwind.css + + + - button.css contains top-level selectors `.ui-button-solid-danger-md`, `.ui-button-solid-danger-md:hover`, `.ui-button-solid-danger-md:focus-visible`, `.ui-button-soft-neutral-md`, `.ui-button-soft-neutral-md:hover`, `.ui-button-soft-neutral-md:focus-visible` (each as its own top-level rule — no `&` nesting). + - Hex codes #b91c1c, #991b1b, #f1f5f9, #e2e8f0, #334155, #64748b present. + - `min-height: 44px` appears for both new classes. + - After `just generate`, backend/static/tailwind.css contains the strings `ui-button-solid-danger-md` and `ui-button-soft-neutral-md`. + - `go build ./...` from backend/ exits 0 (sanity — no Go changes here but verifying nothing breaks). + + CSS classes present, Tailwind regenerated, no other files modified. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → web server | Untrusted form input crosses into POST /tablos handlers (Plans 02/03); Phase 1 sets the test surface but introduces no new boundary. | +| web server → Postgres | SQL parameters from sqlc bindings are parameterised ($N) — no string concat. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-01 | Information disclosure | tablos table FK to users | mitigate | ON DELETE CASCADE on user_id ensures orphan-free deletes; tablos_user_id_idx supports per-user query bounded by user_id (no cross-tenant scans). | +| T-03-02 | Tampering | sqlc query layer | mitigate | All five queries use positional $N parameters (no string interpolation); sqlc generates type-safe Go bindings consumed by Plans 02/03. | +| T-03-03 | DoS | tablos.title column | accept (mitigated by Plans 02/03) | Schema accepts arbitrary-length text; per-request length validation (<=255) happens in Plans 02/03 handlers. Documented here so reviewers know length-cap is layered above the DB. | +| T-03-04 | Repudiation | tablos audit trail | accept | No audit log table in v1 per ROADMAP scope; created_at/updated_at provide minimum forensic signal. | +| T-03-05 | Tampering | Integration test fixtures | mitigate | handlers_tablos_test.go uses setupTestDB per-test schema isolation (D-09 carryover from Phase 2) — no test pollutes another's data. | + + + +- `cd backend && just generate` runs without error. +- `cd backend && go build ./...` exits 0. +- `cd backend && go test ./internal/web/... -run TestTablo` lists all 10 TABLO tests, all FAIL (RED state expected). +- `grep -c "^func Test" backend/internal/web/handlers_tablos_test.go` >= 10. +- `psql` against the local compose DB shows tablos table with the 7 columns from D-02 after `just migrate up`. +- New button CSS classes appear in backend/static/tailwind.css. + + + +1. backend/migrations/0003_tablos.sql exists and applies cleanly to a fresh DB via goose. +2. backend/internal/db/queries/tablos.sql defines the 5 queries; sqlc regeneration succeeds. +3. backend/internal/db/sqlc/tablos.sql.go and updated models.go are committed (generated, not hand-written). +4. backend/internal/web/handlers_tablos_test.go compiles, declares all 10 named test functions, and currently fails at runtime (handlers missing — Plans 02/03 turn it green). +5. backend/internal/web/ui/button.css declares the two new variants; Tailwind rebuild emits them into static/tailwind.css. +6. No regression: existing `just test` suite (Phase 1/2 tests) still passes. + + + +After completion, create `.planning/phases/03-tablos-crud/03-01-SUMMARY.md` covering: migration shape, query list, generated bindings overview, button CSS additions, and a count of failing TABLO tests (expected: 10/10 fail before Plans 02/03). + diff --git a/.planning/phases/03-tablos-crud/03-02-PLAN.md b/.planning/phases/03-tablos-crud/03-02-PLAN.md new file mode 100644 index 0000000..d75554e --- /dev/null +++ b/.planning/phases/03-tablos-crud/03-02-PLAN.md @@ -0,0 +1,324 @@ +--- +phase: 03-tablos-crud +plan: 02 +type: execute +wave: 2 +depends_on: [01] +files_modified: + - backend/internal/web/handlers_tablos.go + - backend/templates/tablos.templ + - backend/templates/tablos_templ.go + - backend/templates/layout.templ + - backend/templates/layout_templ.go + - backend/templates/index.templ + - backend/templates/index_templ.go + - backend/internal/web/handlers.go + - backend/internal/web/router.go + - backend/cmd/web/main.go +autonomous: true +requirements: + - TABLO-01 + - TABLO-02 + - TABLO-06 +tags: + - go + - htmx + - templ + - chi + - csrf + +must_haves: + truths: + - "Signed-in user visiting GET / sees a dashboard titled 'Your Tablos' listing only their tablos newest-first" + - "Empty state renders 'No tablos yet' + 'Create your first tablo to get started.' when user has zero tablos" + - "Clicking 'New tablo' fetches an inline form into #create-form-slot via hx-get /tablos/new" + - "Submitting a valid POST /tablos via HTMX inserts the row, returns 200 with HX-Retarget: #tablos-list + HX-Reswap: afterbegin, prepends a new TabloCard, and clears #create-form-slot via OOB swap" + - "Submitting POST /tablos without HTMX inserts the row and 303-redirects to /" + - "Empty-title POST returns 422 with 'Title is required.' field error" + artifacts: + - path: "backend/internal/web/handlers_tablos.go" + provides: "TablosDeps, TablosListHandler, TablosNewHandler, TablosCreateHandler" + contains: "TablosCreateHandler" + - path: "backend/templates/tablos.templ" + provides: "TablosDashboard, TablosEmptyState, TabloCard, TabloCreateFormFragment, TabloCardWithOOBFormClear" + contains: "TabloCardWithOOBFormClear" + - path: "backend/internal/web/router.go" + provides: "GET / → TablosListHandler, GET /tablos/new, POST /tablos route registration" + contains: "TablosListHandler" + - path: "backend/cmd/web/main.go" + provides: "Construct TablosDeps and pass to NewRouter" + contains: "TablosDeps{Queries: q}" + key_links: + - from: "backend/internal/web/router.go" + to: "TablosCreateHandler" + via: "r.Post(\"/tablos\", ...)" + pattern: "r\\.Post\\(\"/tablos\"" + - from: "backend/templates/tablos.templ" + to: "ui.CSRFField" + via: "@ui.CSRFField(csrfToken) inside create form" + pattern: "ui\\.CSRFField" + - from: "TablosCreateHandler" + to: "Queries.InsertTablo" + via: "sqlc binding" + pattern: "InsertTablo" +--- + + +First vertical slice of Phase 3: a signed-in user can SEE their tablos on the dashboard and CREATE a new tablo end-to-end via HTMX, with a graceful non-JS POST fallback. Turns TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation green (TABLO-01, TABLO-02, TABLO-06 partially). + +Purpose: Deliver real user-visible value as the first slice — the dashboard renders, the create flow works, and the user can see a brand-new tablo appear without a full reload. + +Output: +- handlers_tablos.go with TablosDeps + List/New/Create handlers +- tablos.templ with dashboard page, empty state, card, create form, OOB-clear template +- router.go updated: replace IndexHandler with TablosListHandler; mount /tablos/new and /tablos +- main.go constructs TablosDeps +- layout.templ footer "Phase 3 · Tablos" +- index.templ + IndexHandler removed (or emptied) — dashboard now lives in tablos.templ + + + +@/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 + + + +@.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 +@backend/internal/web/handlers_auth.go +@backend/internal/web/router.go +@backend/cmd/web/main.go +@backend/templates/auth_signup.templ +@backend/templates/index.templ +@backend/templates/layout.templ +@backend/internal/web/ui/button.templ +@backend/internal/web/ui/card.templ +@backend/internal/web/ui/variants.go +@backend/internal/db/sqlc/tablos.sql.go + + + + +TablosDeps (declared in backend/internal/web/handlers_tablos.go): +- Queries *sqlc.Queries + +Handler constructors (signature pattern matches AuthDeps): +- TablosListHandler(deps TablosDeps) http.HandlerFunc → GET / +- TablosNewHandler(deps TablosDeps) http.HandlerFunc → GET /tablos/new (returns TabloCreateFormFragment) +- TablosCreateHandler(deps TablosDeps) http.HandlerFunc → POST /tablos + +NewRouter new signature (replaces existing): + func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler + +Templates (callable by Plan 03): +- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) — full page +- TablosEmptyState() — empty list state +- TabloCard(t sqlc.Tablo, csrfToken string) — single card on dashboard; wraps in ui.Card with class "tablo-delete-zone" on delete button container +- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) — inline create form +- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — card + OOB div clearing #create-form-slot + +Form types (declared in backend/templates/tablos.templ or a sibling tablos_forms.go file, mirroring auth_forms.go): +- TabloCreateForm { Title, Description, Color string } +- TabloCreateErrors { Title, General string } + + + + + + + Task 1: tablos.templ — dashboard, empty state, card, create form, OOB-clear (+ generate) + backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go, backend/templates/layout.templ, backend/templates/layout_templ.go + + backend/templates/auth_signup.templ + backend/templates/auth_forms.go + backend/templates/auth_form_errors.templ + backend/templates/index.templ + backend/templates/layout.templ + backend/internal/web/ui/card.templ + backend/internal/web/ui/button.templ + backend/internal/web/ui/variants.go + backend/internal/web/ui/csrf_field.templ + backend/internal/db/sqlc/models.go (Tablo struct) + .planning/phases/03-tablos-crud/03-UI-SPEC.md (Component Inventory, Interaction Contracts §1+§2, Copywriting Contract) + .planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog block + OOB swap template shape) + .planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 4 dual-target swap, Pitfall 5 top-level OOB) + + + - TablosDashboard renders inside @Layout("Tablos — Xtablo", user, csrfToken). + - Page heading "Your Tablos" using `text-[28px] font-semibold leading-tight` per UI-SPEC Typography. + - "New tablo" button (ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD, Type "button") fires hx-get="/tablos/new", hx-target="#create-form-slot", hx-swap="innerHTML". + - `
` rendered above `
`. + - When tablos slice is empty → @TablosEmptyState() rendered inside #tablos-list. + - Empty state copy exactly: heading "No tablos yet", body "Create your first tablo to get started.", CTA button "New tablo" with aria-label "Create your first tablo" and same hx-get="/tablos/new" attrs. + - TabloCard renders inside @ui.Card with: title (Heading style `text-xl font-semibold leading-snug`), optional description paragraph only when `tablo.Description.Valid && tablo.Description.String != ""` (Pitfall 6), optional color dot (only when `tablo.Color.Valid && tablo.Color.String != ""`) using `inline-block w-2.5 h-2.5 rounded-full` with inline `style="background-color: {tablo.Color.String}"`, link "View" to `/tablos/{id}`, and a `.tablo-delete-zone` wrapper containing the Delete button. (Plan 03 wires delete-confirm; Plan 02 just needs the zone div + a Delete button stub.) + - TabloCreateFormFragment renders a `
` with @ui.CSRFField(csrfToken), @GeneralError(errs.General), labelled Title input (required), Description textarea (3 rows, optional), Color text input (placeholder "#6366f1 or indigo", optional), submit button "Create tablo", cancel link/button that fires `hx-get="/tablos/new/cancel"` OR simpler: clears the slot via hx-get to an endpoint that returns empty — for Phase 02 use a "Cancel" button with `hx-on:click` not allowed (no JS); use a plain anchor `Cancel` that reloads / and resets the slot. + - Field error rendering uses @FieldError(errs.Title) directly under the Title input. + - TabloCardWithOOBFormClear renders @TabloCard(...) as the primary card AND a top-level sibling `
` (must be siblings, not nested per Pitfall 5). + - layout.templ footer changes from "Phase 2 · Authentication" to "Phase 3 · Tablos". + + + Create backend/templates/tablos.templ (package templates) and a sibling backend/templates/tablos_forms.go declaring `type TabloCreateForm struct { Title, Description, Color string }` and `type TabloCreateErrors struct { Title, General string }` (mirrors auth_forms.go). + + Implement these templ components per the behavior block: + - TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) + - TablosEmptyState() + - TabloCard(t sqlc.Tablo, csrfToken string) — include `
` wrapper around the Delete button. The Delete button for now uses `hx-get="/tablos/{id}/delete-confirm"`, `hx-target="closest .tablo-delete-zone"`, `hx-swap="outerHTML"`, Variant ButtonVariantDanger, Tone ButtonToneSoft, Size SizeMD, Type "button". Plan 03 implements /delete-confirm. + - TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) + - TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — emit @TabloCard then a `
` as TOP-LEVEL siblings inside the template body (no wrapping container). + + Imports in tablos.templ: "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". Use `{ tablo.Title }`, `{ tablo.Description.String }`, etc. — templ auto-escapes (V5 XSS). + + Modify backend/templates/layout.templ footer text from "Phase 2 · Authentication" → "Phase 3 · Tablos". No other layout edits. + + Run `just generate` (or `templ generate`) to produce tablos_templ.go and the updated layout_templ.go. Pitfall 3. + + + cd backend && just generate && grep -q "templ TablosDashboard" templates/tablos.templ && grep -q "templ TablosEmptyState" templates/tablos.templ && grep -q "templ TabloCard" templates/tablos.templ && grep -q "templ TabloCreateFormFragment" templates/tablos.templ && grep -q "templ TabloCardWithOOBFormClear" templates/tablos.templ && grep -q "id=\"create-form-slot\" hx-swap-oob=\"true\"" templates/tablos.templ && grep -q "Phase 3 · Tablos" templates/layout.templ && grep -q "Create your first tablo to get started." templates/tablos.templ && grep -q "No tablos yet" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "type TabloCreateForm" templates/tablos_forms.go && go build ./... + + + - File backend/templates/tablos.templ exists with `package templates` and imports "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". + - File contains the strings: `templ TablosDashboard`, `templ TablosEmptyState`, `templ TabloCard`, `templ TabloCreateFormFragment`, `templ TabloCardWithOOBFormClear`. + - File contains exact copy strings (UI-SPEC Copywriting Contract): "Your Tablos", "New tablo", "Create a tablo", "Create tablo", "No tablos yet", "Create your first tablo to get started.". + - File contains `id="create-form-slot"` and `id="tablos-list"`. + - TabloCardWithOOBFormClear emits `
` as a top-level sibling (grep shows it OUTSIDE the @TabloCard call — verify by reading the templ component body). + - File contains `class="tablo-delete-zone"` wrapper around the Delete button. + - tablos.templ guards description rendering with `if tablo.Description.Valid && tablo.Description.String != ""` and color dot with `if tablo.Color.Valid && tablo.Color.String != ""`. + - tablos_forms.go declares `type TabloCreateForm struct` with Title, Description, Color string fields and `type TabloCreateErrors struct` with Title, General string fields. + - templates/layout.templ now contains "Phase 3 · Tablos" and does NOT contain "Phase 2 · Authentication". + - `just generate` produced tablos_templ.go and refreshed layout_templ.go. + - `go build ./...` exits 0. + + Templates compile, helper form/errors types declared, layout footer updated. + + + + Task 2: handlers_tablos.go — TablosDeps + List/New/Create handlers + router/main wiring + backend/internal/web/handlers_tablos.go, backend/internal/web/router.go, backend/internal/web/handlers.go, backend/templates/index.templ, backend/templates/index_templ.go, backend/cmd/web/main.go + + backend/internal/web/handlers_auth.go + backend/internal/web/handlers.go + backend/internal/web/router.go + backend/internal/web/middleware.go + backend/cmd/web/main.go + backend/internal/db/sqlc/tablos.sql.go + backend/internal/db/sqlc/models.go + backend/internal/auth/middleware.go (Authed) + backend/internal/web/handlers_tablos_test.go (RED tests from Plan 01) + .planning/phases/03-tablos-crud/03-PATTERNS.md (handlers_tablos.go analog block + router/main modify blocks) + .planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 1-4, 7, 8 + Pitfalls 1, 2, 5, 6) + .planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §1 + §2 + Validation copy) + + + - TablosDeps struct holds *sqlc.Queries. + - TablosListHandler: GET / — extracts user via auth.Authed(ctx); calls Queries.ListTablosByUser(ctx, user.ID); renders TablosDashboard with the slice (empty slice OK, template handles empty state); Content-Type: text/html; charset=utf-8. + - TablosNewHandler: GET /tablos/new — renders TabloCreateFormFragment with zero-value form/errs; HTMX-only intent but works without HX-Request too. + - TablosCreateHandler: POST /tablos — reads title/description/color via r.PostFormValue (NOT r.Body — Pitfall 2); validates: title required → "Title is required.", title len > 255 → "Title must be 255 characters or fewer." (UI-SPEC copy). On validation error: status 422, render TabloCreateFormFragment with field errors (HTMX path) or full TablosDashboard with errs (non-HTMX path). Build InsertTabloParams: Title=trimmed title, Description=pgtype.Text{Valid: description != "", String: description}, Color=pgtype.Text{Valid: color != "", String: color}, UserID=user.ID. On success + HX-Request: set HX-Retarget: #tablos-list, HX-Reswap: afterbegin, render TabloCardWithOOBFormClear. On success + non-HTMX: 303 to /. + - All three handlers extract *auth.User via `_, user, _ := auth.Authed(r.Context())`. + - Router signature changes to NewRouter(pinger, staticDir, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string). + - Inside the RequireAuth chi group: GET / → TablosListHandler(tabloDeps), GET /tablos/new → TablosNewHandler(tabloDeps), POST /tablos → TablosCreateHandler(tabloDeps). Static segments declared before parametric (Pitfall 1) — Plan 03 will add /tablos/{id} routes AFTER /tablos/new. + - cmd/web/main.go constructs `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter. + - Delete the old IndexHandler from handlers.go (and templates.Index in index.templ → emptied or file deleted). DemoTimeHandler stays. If deleting index.templ causes any remaining references to break, leave the file but reduce it to a no-op package declaration; remove IndexHandler from handlers.go either way. + - Stub for Plan 03: also register placeholder routes for endpoints the test scaffold exercises to avoid 404 noise during partial green: leave undefined; Plan 03 implements them. Tests for those endpoints stay RED until Plan 03. + + + 1) Replace the Plan 01 stub `type TablosDeps struct { Queries *sqlc.Queries }` (or create) at the top of backend/internal/web/handlers_tablos.go in package web. Add full handler implementations following handlers_auth.go style exactly (constructor returns http.HandlerFunc closing over deps). + + 2) Implement TablosListHandler, TablosNewHandler, TablosCreateHandler per the behavior block. For nullable insert params use `pgtype.Text{String: s, Valid: s != ""}` (import "github.com/jackc/pgx/v5/pgtype"). For trim/validate, use `strings.TrimSpace`. For error responses use `w.WriteHeader(http.StatusUnprocessableEntity)` + form fragment (HX) or full dashboard (non-HX). For HTMX success use w.Header().Set("HX-Retarget", "#tablos-list") and w.Header().Set("HX-Reswap", "afterbegin"). Status 200 for HTMX success; 303 to "/" otherwise (http.StatusSeeOther — Pitfall 9 from Phase 2 carries over). + + 3) Edit backend/internal/web/router.go: change NewRouter signature to add `tabloDeps TablosDeps` as the parameter immediately after `deps AuthDeps`. Inside the existing protected group, replace `r.Get("/", IndexHandler())` with `r.Get("/", TablosListHandler(tabloDeps))` and add (in this order — static-before-parametric, Pitfall 1): + - r.Get("/tablos/new", TablosNewHandler(tabloDeps)) + - r.Post("/tablos", TablosCreateHandler(tabloDeps)) + Do NOT yet add /tablos/{id} routes — Plan 03 owns those. + + 4) Edit backend/cmd/web/main.go: after the existing `deps := web.AuthDeps{...}` line, add `tabloDeps := web.TablosDeps{Queries: q}` and pass it as the new arg to NewRouter (positional, between deps and csrfKey). + + 5) Remove backend/internal/web/handlers.go IndexHandler. Remove backend/templates/index.templ (delete or reduce to bare `package templates`). Delete backend/templates/index_templ.go if `just generate` does not regenerate it (templ should remove it on next run if the .templ is gone; if leftover, delete manually). + + 6) Run `just generate` then `just test`. TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation must turn green; tests for endpoints not yet implemented (Plan 03) remain red. + + + cd backend && just generate && grep -q "type TablosDeps struct" internal/web/handlers_tablos.go && grep -q "func TablosListHandler" internal/web/handlers_tablos.go && grep -q "func TablosNewHandler" internal/web/handlers_tablos.go && grep -q "func TablosCreateHandler" internal/web/handlers_tablos.go && grep -q "HX-Retarget" internal/web/handlers_tablos.go && grep -q "HX-Reswap" internal/web/handlers_tablos.go && grep -q "pgtype.Text" internal/web/handlers_tablos.go && grep -q "r.PostFormValue(\"title\")" internal/web/handlers_tablos.go && grep -q "tabloDeps TablosDeps" internal/web/router.go && grep -q "r.Get(\"/tablos/new\"" internal/web/router.go && grep -q "r.Post(\"/tablos\"" internal/web/router.go && grep -q "TablosDeps{Queries: q}" cmd/web/main.go && ! grep -q "IndexHandler" internal/web/handlers.go && go build ./... && go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation" -count=1 + + + - backend/internal/web/handlers_tablos.go declares: `type TablosDeps struct { Queries *sqlc.Queries }`, `func TablosListHandler(deps TablosDeps) http.HandlerFunc`, `func TablosNewHandler(deps TablosDeps) http.HandlerFunc`, `func TablosCreateHandler(deps TablosDeps) http.HandlerFunc`. + - handlers_tablos.go uses `r.PostFormValue("title")`, `r.PostFormValue("description")`, `r.PostFormValue("color")` (no r.Body reads). + - handlers_tablos.go contains literal "Title is required." and "Title must be 255 characters or fewer." strings. + - handlers_tablos.go sets HX-Retarget and HX-Reswap headers in the create-success path. + - handlers_tablos.go uses pgtype.Text{...Valid: s != ""...} for nullable Description and Color inserts. + - router.go signature reads `func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler`. + - router.go protected group contains `r.Get("/", TablosListHandler(tabloDeps))` (no IndexHandler reference), `r.Get("/tablos/new", TablosNewHandler(tabloDeps))` (declared BEFORE any parametric /tablos/{id} route which is added in Plan 03), `r.Post("/tablos", TablosCreateHandler(tabloDeps))`. + - cmd/web/main.go contains `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter. + - backend/internal/web/handlers.go no longer contains the symbol `IndexHandler` (`grep -c IndexHandler handlers.go` == 0). + - `go build ./...` exits 0. + - `go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation"` exits 0 (all four pass). + - Phase 1/2 tests still pass: `go test ./internal/web/... -run "TestSignup|TestLogin|TestLogout|TestCSRF"` exits 0. + + Dashboard + create slice green; create-form-slot OOB clear verified; non-HTMX 303 verified. + + + + Task 3: Human verify list + create flow + + Dashboard at GET / showing user's tablos newest-first or empty state. "New tablo" button expands inline form via HTMX; submitting creates a row, clears the form slot, and prepends the new card without a full reload. Non-JS fallback works via plain form POST. + + + 1. cd backend && just dev (web server on local port). + 2. Open http://localhost:PORT/login, log in with an existing test account (or sign up). + 3. On the dashboard, confirm heading "Your Tablos" and either tablo cards or empty-state text "No tablos yet" + "Create your first tablo to get started.". + 4. Click "New tablo" — inline form appears above the list, no full page reload (check DevTools Network panel: only XHR to /tablos/new). + 5. Submit form with title "My first tablo" + description "Hello" + color "#6366f1": form collapses, new card appears at top of list, no full reload. + 6. Submit form with empty title: form stays open, "Title is required." appears under the field. + 7. Open a private window with no JS — submit the form: page does a real POST and redirects to /; new tablo appears in list. + 8. View page source: verify CSRF token is rendered in the form's hidden _csrf field. + + Type "approved" if the dashboard + create flow works as described, or describe issues. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → POST /tablos | Untrusted form input (title, description, color) crosses into the handler. | +| Browser → GET /tablos/new | Authed GET; no body input. | +| Handler → Postgres | Parameterised via sqlc-generated InsertTabloParams. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-02-01 | Spoofing | session cookie | mitigate (inherited) | RequireAuth middleware from Phase 2 wraps the entire tablos group — unauthed requests redirect to /login. | +| T-03-02-02 | Tampering | CSRF on POST /tablos | mitigate | gorilla/csrf middleware from Phase 2 validates _csrf field; @ui.CSRFField(csrfToken) embedded in TabloCreateFormFragment. Missing token → 403 before handler runs. | +| T-03-02-03 | Tampering | r.Body drained by gorilla/csrf | mitigate | Use r.PostFormValue exclusively (Pitfall 2). | +| T-03-02-04 | DoS | over-long title | mitigate | TablosCreateHandler validates len(strings.TrimSpace(title)) > 0 AND <= 255; >255 returns 422 before DB insert. | +| T-03-02-05 | XSS via title/description/color | Tampering | mitigate | templ auto-escapes all `{ variable }` interpolations; color rendered into inline style attribute is templ-escaped (inline style accepts limited chars, but templ's escaping prevents attribute breakout). No templ.Raw anywhere. | +| T-03-02-06 | Tampering via OOB swap | hx-swap-oob top-level requirement | mitigate | TabloCardWithOOBFormClear emits the OOB div as a top-level sibling (Pitfall 5); template structure asserted in Task 1 acceptance criteria. | +| T-03-02-07 | Elevation of privilege | Listing other users' tablos | mitigate | ListTablosByUser query is scoped by $1 = user.ID from auth.Authed(ctx); no path parameters, no way to widen scope. | + + + +- TestTabloList, TestTabloList_Empty, TestTabloCreate (incl. HTMX + non-HTMX sub-asserts), TestTabloCreate_Validation all pass. +- Full Phase 1/2 test suite still passes (no regression in TestSignup/TestLogin/TestLogout/TestCSRF). +- Manual browser verification of create flow per Task 3 checkpoint. +- `grep -c IndexHandler backend/internal/web/handlers.go` returns 0. + + + +1. Dashboard at GET / renders user's tablos newest-first (TABLO-01). +2. Empty state appears when user has zero tablos with exact UI-SPEC copy. +3. Creating a tablo via HTMX prepends a new card and clears the form slot in one round trip (TABLO-02 + TABLO-06). +4. Empty-title validation surfaces "Title is required." inline. +5. Non-HTMX POST /tablos redirects 303 to / (TABLO-06 degrade-gracefully). +6. CSRF token present in the create form (AUTH-06 inherited). +7. Plan 03 has the wiring it needs: TablosDeps available, NewRouter signature stable, TabloCard renders a `.tablo-delete-zone` placeholder. + + + +After completion, create `.planning/phases/03-tablos-crud/03-02-SUMMARY.md` documenting: TablosDeps shape, list of new handlers/templates, NewRouter signature change, which tests turned green (4 of 10), and which remain red for Plan 03 (6 of 10). + diff --git a/.planning/phases/03-tablos-crud/03-03-PLAN.md b/.planning/phases/03-tablos-crud/03-03-PLAN.md new file mode 100644 index 0000000..667094f --- /dev/null +++ b/.planning/phases/03-tablos-crud/03-03-PLAN.md @@ -0,0 +1,337 @@ +--- +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" +--- + + +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 + + + +@/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 + + + +@.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 `` 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. + + + + + +## 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. | + + + +- 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). + + + +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. + + + +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. + diff --git a/.planning/phases/03-tablos-crud/03-PATTERNS.md b/.planning/phases/03-tablos-crud/03-PATTERNS.md new file mode 100644 index 0000000..86547cb --- /dev/null +++ b/.planning/phases/03-tablos-crud/03-PATTERNS.md @@ -0,0 +1,702 @@ +# Phase 3: Tablos CRUD - Pattern Map + +**Mapped:** 2026-05-14 +**Files analyzed:** 9 new/modified files +**Analogs found:** 9 / 9 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `backend/migrations/0003_tablos.sql` | migration | batch | `backend/migrations/0002_auth.sql` | exact | +| `backend/internal/db/queries/tablos.sql` | query | CRUD | `backend/internal/db/queries/users.sql` | role-match | +| `backend/internal/web/handlers_tablos.go` | handler | request-response | `backend/internal/web/handlers_auth.go` | exact | +| `backend/templates/tablos.templ` | component | request-response | `backend/templates/auth_signup.templ` + `backend/templates/index.templ` | exact | +| `backend/internal/web/router.go` | config | request-response | `backend/internal/web/router.go` (self — modify) | self | +| `backend/cmd/web/main.go` | config | request-response | `backend/cmd/web/main.go` (self — modify) | self | +| `backend/templates/layout.templ` | component | request-response | `backend/templates/layout.templ` (self — modify footer) | self | +| `backend/templates/index.templ` | component | request-response | `backend/templates/index.templ` (self — delete or empty) | self | +| `backend/internal/web/ui/button.css` | config | — | `backend/internal/web/ui/button.css` (self — add variants) | self | + +--- + +## Pattern Assignments + +### `backend/migrations/0003_tablos.sql` (migration, batch) + +**Analog:** `backend/migrations/0002_auth.sql` + +**Header comment and goose Up/Down pattern** (lines 1-30 of analog): +```sql +-- migrations/0002_auth.sql +-- Phase 2: Authentication — users + sessions tables. + +-- +goose Up +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL UNIQUE, + password_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX sessions_user_id_idx ON sessions(user_id); + +-- +goose Down +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; +-- citext + pgcrypto left in place — extensions are cheap and shared across migrations. +``` + +**Copy conventions:** +- Header comment: `-- migrations/NNNN_name.sql` then `-- Phase N: Description` +- UUID PKs via `DEFAULT gen_random_uuid()` (no import needed — pgcrypto loaded in 0002) +- `timestamptz NOT NULL DEFAULT now()` for all timestamp columns +- `ON DELETE CASCADE` for FK to users(id) +- Named index pattern: `tablename_columnname_idx` +- `-- +goose Down` drops tables in reverse dependency order +- Comment on retained extensions at Down block end + +--- + +### `backend/internal/db/queries/tablos.sql` (query, CRUD) + +**Analog:** `backend/internal/db/queries/users.sql` + +**Full analog file** (lines 1-10): +```sql +-- name: InsertUser :one +INSERT INTO users (email, password_hash) +VALUES ($1, $2) +RETURNING id, email, password_hash, created_at, updated_at; + +-- name: GetUserByEmail :one +SELECT id, email, password_hash, created_at, updated_at +FROM users +WHERE email = $1; +``` + +**Copy conventions:** +- `-- name: FuncName :one/:many/:exec` annotation on every query (sqlc requires it) +- Explicit column list in SELECT (no `SELECT *`) +- `RETURNING` on INSERT/UPDATE to get the full row back without a second query +- `$1, $2, ...` positional params (pgx/v5 style) +- `:exec` for DELETE (no return value needed) +- `:one` for INSERT/UPDATE RETURNING + single-row SELECT +- `:many` for list queries + +**Note on nullable columns:** `description text` and `color text` are nullable. sqlc with pgx/v5 generates `pgtype.Text` for these. Templates must check `.Valid` before using `.String`. + +--- + +### `backend/internal/web/handlers_tablos.go` (handler, request-response) + +**Analog:** `backend/internal/web/handlers_auth.go` + +**Imports pattern** (lines 1-18): +```go +package web + +import ( + "errors" + "log/slog" + "net" + "net/http" + "net/mail" + "strings" + + "backend/internal/auth" + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/gorilla/csrf" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) +``` + +**Deps struct pattern** (lines 25-30): +```go +// AuthDeps holds the dependencies shared by all auth handlers. +type AuthDeps struct { + Queries *sqlc.Queries + Store *auth.Store + Secure bool + Limiter *auth.LimiterStore +} +``` + +For tablos, copy this as: +```go +// TablosDeps holds dependencies for all tablo handlers. +type TablosDeps struct { + Queries *sqlc.Queries +} +``` + +**Handler constructor pattern** (lines 50-55): +```go +// SignupPageHandler renders the GET /signup page with an empty form. +func SignupPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r)).Render(r.Context(), w) + } +} +``` + +Every handler returns `http.HandlerFunc` from a constructor that closes over the deps struct. + +**HTMX-aware redirect pattern** (lines 137-143): +```go +// HTMX form submissions receive HX-Redirect so HTMX handles navigation client-side. +// Plain (no-JS) form submissions receive 303 See Other (NOT 302 — Pitfall 9). +if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + return +} +http.Redirect(w, r, "/", http.StatusSeeOther) +``` + +**Fragment vs full-page dispatch pattern** (lines 149-157): +```go +func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, status int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if r.Header.Get("HX-Request") == "true" { + _ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w) + } else { + _ = templates.SignupPage(form, errs, csrf.Token(r)).Render(r.Context(), w) + } +} +``` + +**Form value reading** (lines 71-72 and 188-189): +```go +// Always r.PostFormValue, never r.Body — gorilla/csrf consumes the body. +email := strings.TrimSpace(r.PostFormValue("email")) +password := r.PostFormValue("password") +``` + +**pgx.ErrNoRows handling** (lines 227-231): +```go +user, err := deps.Queries.GetUserByEmail(ctx, normalized) +if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // handle not found + return + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return +} +``` + +**auth.Authed usage** (lines 248-251): +```go +// Extract authenticated session — RequireAuth middleware guarantees this is set. +sess, _, ok := auth.Authed(r.Context()) +if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return +} +``` + +For tablo handlers the three-value form is: +```go +_, user, _ := auth.Authed(r.Context()) +// user is guaranteed non-nil inside RequireAuth-gated routes +``` + +**Ownership check pattern** (new for Phase 3 — derived from D-04): +```go +tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID) +if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return +} +// D-04: 404 for non-owner, not 403 — no information leakage +if tablo.UserID != user.ID { + http.NotFound(w, r) + return +} +``` + +**slog error logging pattern** (lines 306-310): +```go +if err := deps.Store.Delete(r.Context(), sess.ID); err != nil { + slog.Default().Error("logout: delete session", "session_id", sess.ID, "err", err) + // Continue — partial invalidation is better than leaving state intact. +} +``` + +--- + +### `backend/templates/tablos.templ` (component, request-response) + +**Analog:** `backend/templates/auth_signup.templ` (form + fragment pattern) and `backend/templates/index.templ` (dashboard page with Card + Button + HTMX attrs) + +**Package declaration and imports** (auth_signup.templ lines 1-3, index.templ lines 1-8): +```go +package templates + +import ( + "backend/internal/auth" + "backend/internal/db/sqlc" + "backend/internal/web/ui" +) +``` + +**Full-page template wrapping Layout** (auth_signup.templ lines 8-19): +```go +templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string) { + @Layout("Sign up", nil, csrfToken) { +
+ @ui.Card(nil) { +
+

Create your account

+ @SignupFormFragment(form, errs, csrfToken) +
+ } +
+ } +} +``` + +**Fragment component with HTMX form** (auth_signup.templ lines 25-72): +```go +templ SignupFormFragment(form SignupForm, errs SignupErrors, csrfToken string) { + + @ui.CSRFField(csrfToken) + @GeneralError(errs.General) + ... + @ui.Button(ui.ButtonProps{ + Label: "Create account", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) + +} +``` + +**Button with HTMX attributes** (index.templ lines 27-39): +```go +@ui.Button(ui.ButtonProps{ + Label: "Fetch server time", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/demo/time", + "hx-target": "#demo-out", + "hx-swap": "innerHTML", + "hx-indicator": "#demo-spinner", + }, +}) +``` + +**ui.Card with arbitrary attrs** (index.templ lines 21-22): +```go +@ui.Card(nil) { + // children here +} +// With attrs: +@ui.Card(templ.Attributes{"id": "tablo-123"}) { + // children here +} +``` + +**pgtype.Text null check in templates** (from RESEARCH Pitfall 6): +```go +if tablo.Description.Valid && tablo.Description.String != "" { +

{ tablo.Description.String }

+} +``` + +**FieldError and GeneralError** (auth_form_errors.templ lines 5-19): +```go +templ FieldError(msg string) { + if msg != "" { +

{ msg }

+ } +} + +templ GeneralError(msg string) { + if msg != "" { +
+ { msg } +
+ } +} +``` + +**OOB swap template shape** (from RESEARCH Pattern 4 — new pattern, no existing analog): +```go +// TabloCardWithOOBFormClear renders a card plus an OOB element to clear #create-form-slot. +// The OOB div MUST be a top-level sibling of the card, not nested (RESEARCH Pitfall 5). +templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { + @TabloCard(tablo, csrfToken) +
+} +``` + +--- + +### `backend/internal/web/router.go` (config, request-response — MODIFIED) + +**Analog:** Self. Current file `backend/internal/web/router.go`. + +**Current protected group pattern** (lines 74-78): +```go +// Protected routes — require an authenticated session (D-23, AUTH-05). +r.Group(func(r chi.Router) { + r.Use(auth.RequireAuth) + r.Get("/", IndexHandler()) + r.Post("/logout", LogoutHandler(deps)) +}) +``` + +**NewRouter signature pattern** (line 47): +```go +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +``` + +**What to add — NewRouter signature change:** +```go +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +``` + +**What to add — protected group extension:** +```go +r.Group(func(r chi.Router) { + r.Use(auth.RequireAuth) + r.Get("/", TablosListHandler(tabloDeps)) // replaces IndexHandler + r.Post("/logout", LogoutHandler(deps)) + // Static segment BEFORE parametric — chi static takes precedence when declared first + r.Get("/tablos/new", TablosNewHandler(tabloDeps)) + r.Post("/tablos", TablosCreateHandler(tabloDeps)) + 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)) +}) +``` + +**Route ordering rule:** `GET /tablos/new` MUST be declared before `GET /tablos/{id}`. chi v5 resolves static segments before parametric at the same depth, but explicit declaration order is safest. + +--- + +### `backend/cmd/web/main.go` (config — MODIFIED) + +**Analog:** Self. Current file `backend/cmd/web/main.go`. + +**Current deps construction and router call** (lines 78-80): +```go +deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl} + +router := web.NewRouter(pool, "./static", deps, csrfKey, env) +``` + +**What to add:** +```go +deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl} +tabloDeps := web.TablosDeps{Queries: q} + +router := web.NewRouter(pool, "./static", deps, tabloDeps, csrfKey, env) +``` + +Note: `tabloDeps` shares the same `*sqlc.Queries` instance (`q`) as `deps`. No new DB pool needed. + +--- + +### `backend/templates/layout.templ` (component — MODIFIED: footer text only) + +**Analog:** Self. Current file `backend/templates/layout.templ`. + +**Current footer** (line 51): +```go +
+ Phase 2 · Authentication +
+``` + +**Change to:** +```go +
+ Phase 3 · Tablos +
+``` + +No other layout changes. All structural classes, container width, asset paths, and logout form are locked by UI-SPEC. + +--- + +### `backend/templates/index.templ` (component — DELETED or emptied) + +**Analog:** Self. Current content replaced by tablo dashboard in `tablos.templ`. + +`IndexHandler()` in `handlers.go` (the Phase 1 placeholder) should also be removed or replaced. The `GET /` route in `router.go` is reassigned to `TablosListHandler(tabloDeps)`. + +The HTMX demo (`/demo/time` + `DemoTimeHandler`) stays — it is not removed in Phase 3. + +--- + +### `backend/internal/web/ui/button.css` (config — MODIFIED: add danger + neutral variants) + +**Analog:** Self. Current file `backend/internal/web/ui/button.css`. + +**Existing variant pattern** (lines 29-48): +```css +.ui-button-solid-default-md { + display: inline-flex; + align-items: center; + border-radius: 0.375rem; + background-color: #2563eb; + padding: 0.5rem 1rem; + font-size: 1rem; + font-weight: 600; + color: #ffffff; +} + +.ui-button-solid-default-md:hover { + background-color: #1d4ed8; +} + +.ui-button-solid-default-md:focus-visible { + outline: 2px solid #1d4ed8; + outline-offset: 2px; +} +``` + +**CSS conventions to follow (line 3-4 comment):** +- No CSS nesting (`&:hover`) — all pseudo-class rules are top-level selectors +- Class name format: `.ui-button-{tone}-{variant}-{size}` (matches `ButtonClass()` in variants.go) + +**New classes needed per UI-SPEC:** + +`ui-button-solid-danger-md` — for delete confirmation button (red, solid): +```css +.ui-button-solid-danger-md { + display: inline-flex; + align-items: center; + border-radius: 0.375rem; + background-color: #dc2626; + padding: 0.5rem 1rem; + font-size: 1rem; + font-weight: 600; + color: #ffffff; +} + +.ui-button-solid-danger-md:hover { + background-color: #b91c1c; +} + +.ui-button-solid-danger-md:focus-visible { + outline: 2px solid #dc2626; + outline-offset: 2px; +} +``` + +`ui-button-soft-neutral-md` — for cancel buttons (ghost-style, muted): +```css +.ui-button-soft-neutral-md { + display: inline-flex; + align-items: center; + border-radius: 0.375rem; + background-color: transparent; + border: 1px solid #cbd5e1; + padding: 0.5rem 1rem; + font-size: 1rem; + font-weight: 500; + color: #475569; +} + +.ui-button-soft-neutral-md:hover { + background-color: #f1f5f9; +} + +.ui-button-soft-neutral-md:focus-visible { + outline: 2px solid #64748b; + outline-offset: 2px; +} +``` + +**Usage in templ:** +```go +@ui.Button(ui.ButtonProps{ + Label: "Delete", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", +}) + +@ui.Button(ui.ButtonProps{ + Label: "Cancel", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/{id}/delete-cancel", + "hx-target": "#tablo-delete-zone", + "hx-swap": "outerHTML", + }, +}) +``` + +Note: `ButtonVariantDanger` and `ButtonVariantNeutral` already exist in `variants.go` (lines 20 and 18). `ButtonToneSoft` exists at line 32. The CSS classes are the only missing piece. + +--- + +## Shared Patterns + +### Authentication — `auth.Authed` extraction +**Source:** `backend/internal/auth/middleware.go` lines 26-32 +**Apply to:** All handler functions in `handlers_tablos.go` + +```go +// Authed extracts the session and user from the request context. +// Returns (session, user, true) when a valid session is present. +func Authed(ctx context.Context) (*Session, *User, bool) { + a, ok := ctx.Value(sessionKey).(*authed) + if !ok || a == nil { + return nil, nil, false + } + return a.Session, a.User, true +} +``` + +Inside `RequireAuth`-gated routes, the user is always present. Use the short form: +```go +_, user, _ := auth.Authed(r.Context()) +``` + +### HTMX-aware redirect helper +**Source:** `backend/internal/auth/middleware.go` lines 119-126 +**Apply to:** `TabloDeleteHandler` (post-delete navigation to `/`) and any handler that redirects + +```go +func redirectTo(w http.ResponseWriter, r *http.Request, target string) { + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", target) + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, target, http.StatusSeeOther) +} +``` + +This is an unexported function in the `auth` package. Replicate it inline in handlers or extract a shared helper in the `web` package. Do not add a new dependency on `auth` internals. + +### CSRF token injection +**Source:** `backend/internal/web/ui/csrf_field.templ` lines 7-9 +**Apply to:** Every `
` in `tablos.templ` + +```go +templ CSRFField(token string) { + +} +``` + +Call as `@ui.CSRFField(csrfToken)` as the first child of every POST form. + +### gorilla/csrf token extraction +**Source:** `backend/internal/web/handlers_auth.go` (throughout — e.g. line 53) +**Apply to:** Every handler that renders a template with a form + +```go +csrf.Token(r) // import "github.com/gorilla/csrf" +``` + +Pass as `csrfToken` parameter to all template constructors. + +### Content-Type header +**Source:** `backend/internal/web/handlers_auth.go` lines 52, 162, 150 +**Apply to:** Every handler that writes HTML + +```go +w.Header().Set("Content-Type", "text/html; charset=utf-8") +``` + +Set before calling `.Render()`. For error responses, set before `w.WriteHeader(status)`. + +### UUID param extraction +**Source:** RESEARCH.md Pattern 6 (verified against go.mod) +**Apply to:** All tablo handlers that accept `{id}` URL param + +```go +import ( + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +idStr := chi.URLParam(r, "id") +tabloID, err := uuid.Parse(idStr) +if err != nil { + http.NotFound(w, r) + return +} +``` + +### Dual-target HTMX create response (HX-Retarget + hx-swap-oob) +**Source:** RESEARCH.md Pattern 4 (new pattern — no prior codebase analog) +**Apply to:** `TablosCreateHandler` success path only + +```go +// Successful create: retarget to #tablos-list, prepend card, OOB-clear form slot. +w.Header().Set("Content-Type", "text/html; charset=utf-8") +w.Header().Set("HX-Retarget", "#tablos-list") +w.Header().Set("HX-Reswap", "afterbegin") +_ = templates.TabloCardWithOOBFormClear(tablo, csrf.Token(r)).Render(r.Context(), w) +``` + +The OOB element MUST be a top-level sibling in the response body, not nested (RESEARCH Pitfall 5). + +### Input validation via r.PostFormValue +**Source:** `backend/internal/web/handlers_auth.go` lines 71-72 +**Apply to:** All POST handlers in `handlers_tablos.go` + +```go +// Pitfall 2: gorilla/csrf consumes r.Body. Always use r.PostFormValue, never +// r.Body / io.ReadAll. r.PostFormValue calls r.ParseForm which caches the result. +title := strings.TrimSpace(r.PostFormValue("title")) +description := r.PostFormValue("description") +color := strings.TrimSpace(r.PostFormValue("color")) +``` + +--- + +## No Analog Found + +No files are entirely without analog. All new files have at least a role-match or structural analog in the codebase. The only genuinely new patterns (OOB swap, HX-Retarget/HX-Reswap, ownership 404 check, UUID param extraction) are covered in the Shared Patterns section above and documented in RESEARCH.md with verification sources. + +--- + +## Metadata + +**Analog search scope:** `backend/internal/web/`, `backend/templates/`, `backend/migrations/`, `backend/internal/db/queries/`, `backend/internal/auth/`, `backend/cmd/web/`, `backend/internal/web/ui/` +**Files read:** 18 +**Pattern extraction date:** 2026-05-14 diff --git a/.planning/phases/03-tablos-crud/03-RESEARCH.md b/.planning/phases/03-tablos-crud/03-RESEARCH.md index 58b4936..72cb781 100644 --- a/.planning/phases/03-tablos-crud/03-RESEARCH.md +++ b/.planning/phases/03-tablos-crud/03-RESEARCH.md @@ -701,19 +701,19 @@ Phase 3 is code/migration/template changes only. All runtime dependencies were e --- -## Open Questions +## Open Questions (RESOLVED) -1. **IndexHandler vs TablosListHandler for GET /** +1. RESOLVED: **IndexHandler vs TablosListHandler for GET /** - What we know: Phase 1/2 registered `IndexHandler` for `GET /` which renders the HTMX demo placeholder. CONTEXT.md says `index.templ` "transforms into the tablo dashboard." - What's unclear: Whether to delete `index.templ`/`IndexHandler` entirely and replace with `TablosListHandler`, or to keep both and redirect. - Recommendation: Delete `templates/Index` and `handlers.go`'s `IndexHandler`, replace `GET /` registration in `router.go` with `TablosListHandler(tabloDeps)`. The HTMX demo (`/demo/time`) can remain for Phase 3. -2. **`_method` override for edit vs plain `POST /tablos/{id}/edit`** +2. RESOLVED: **`_method` override for edit vs plain `POST /tablos/{id}/edit`** - What we know: CONTEXT.md marks this as Claude's discretion. chi supports `_method` override via `chimw.MethodOverride` middleware if added. - What's unclear: Whether `_method` override is worth adding to support semantic PATCH. - Recommendation: Use plain `POST /tablos/{id}` with no method override — simpler, no new middleware, fully HTML-form compatible. The route can infer "update" from the POST + path. The UI-SPEC already specifies `hx-post="/tablos/{id}"`. -3. **Color field validation** +3. RESOLVED: **Color field validation** - What we know: `color` is `text` nullable. CONTEXT.md says "validation at planner's discretion." - What's unclear: Whether to validate hex format or Tailwind class names. - Recommendation: Accept any non-empty string up to 32 chars. Render as inline style `background-color: {{ color }}` with the dot element. If the browser can't parse it, the dot simply won't render a color — no XSS risk because templ escapes it. diff --git a/.planning/phases/03-tablos-crud/03-VALIDATION.md b/.planning/phases/03-tablos-crud/03-VALIDATION.md new file mode 100644 index 0000000..a951006 --- /dev/null +++ b/.planning/phases/03-tablos-crud/03-VALIDATION.md @@ -0,0 +1,85 @@ +--- +phase: 03 +slug: tablos-crud +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-05-14 +--- + +# Phase 03 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Go standard `testing` package + `net/http/httptest` | +| **Config file** | none — `go test ./...` from `backend/` | +| **Quick run command** | `go test ./internal/web/... -run TestTablo` | +| **Full suite command** | `just test` (runs `just generate` first, then `go test ./...`) | +| **Estimated runtime** | ~30 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `go test ./internal/web/... -run TestTablo` +- **After every plan wave:** Run `just test` +- **Before `/gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 30 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 03-list | 01 | 1 | TABLO-01 | T-ownership | Authed user sees only their tablos | integration | `go test ./internal/web/... -run TestTabloList` | ❌ W0 | ⬜ pending | +| 03-list-empty | 01 | 1 | TABLO-01 | — | Empty state renders CTA | integration | `go test ./internal/web/... -run TestTabloList_Empty` | ❌ W0 | ⬜ pending | +| 03-create | 01 | 1 | TABLO-02 | T-csrf | CSRF token validated on POST | integration | `go test ./internal/web/... -run TestTabloCreate` | ❌ W0 | ⬜ pending | +| 03-create-validation | 01 | 1 | TABLO-02 | T-input | Empty title returns 422 + errors | integration | `go test ./internal/web/... -run TestTabloCreate_Validation` | ❌ W0 | ⬜ pending | +| 03-detail-owner | 02 | 1 | TABLO-03 | T-ownership | Owner sees detail page | integration | `go test ./internal/web/... -run TestTabloDetail_Owner` | ❌ W0 | ⬜ pending | +| 03-detail-nonowner | 02 | 1 | TABLO-03 | T-ownership | Non-owner gets 404 (not 403) | integration | `go test ./internal/web/... -run TestTabloDetail_NonOwner` | ❌ W0 | ⬜ pending | +| 03-detail-invalid | 02 | 1 | TABLO-03 | T-input | Invalid UUID gets 404 | integration | `go test ./internal/web/... -run TestTabloDetail_InvalidID` | ❌ W0 | ⬜ pending | +| 03-edit | 02 | 2 | TABLO-04 | T-csrf | CSRF validated; returns display fragment | integration | `go test ./internal/web/... -run TestTabloUpdate` | ❌ W0 | ⬜ pending | +| 03-delete-confirm | 02 | 2 | TABLO-05 | — | Returns confirm fragment | integration | `go test ./internal/web/... -run TestTabloDeleteConfirm` | ❌ W0 | ⬜ pending | +| 03-delete | 02 | 2 | TABLO-05 | T-csrf | CSRF validated; row removed | integration | `go test ./internal/web/... -run TestTabloDelete` | ❌ W0 | ⬜ pending | +| 03-htmx-fragment | 01 | 1 | TABLO-06 | — | HX-Request=true → fragment, not full page | integration | part of TestTabloCreate | ❌ W0 | ⬜ pending | +| 03-htmx-degrade | 01 | 1 | TABLO-06 | — | Non-HTMX POST → 303 redirect | integration | part of TestTabloCreate | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `backend/internal/web/handlers_tablos_test.go` — integration tests for all TABLO-01..06 paths (follows `handlers_auth_test.go` pattern with real Postgres via `setupTestDB`) +- [ ] `backend/internal/db/queries/tablos.sql` — must exist and `just generate` must complete before tests compile +- [ ] `backend/migrations/0003_tablos.sql` — goose picks this up automatically during `setupTestDB` (`goose.Up`) + +--- + +## Security Threat Model + +### Applicable ASVS Categories (L1) + +| ASVS Category | Applies | Control | +|---------------|---------|---------| +| V2 Authentication | yes (inherited) | gorilla/csrf + session cookie from Phase 2 | +| V3 Session Management | yes (inherited) | auth.ResolveSession + RequireAuth from Phase 2 | +| V4 Access Control | **yes — new** | Ownership check in every handler; 404 for non-owner (D-04) | +| V5 Input Validation | **yes — new** | title required + max 255; color max 32 chars | +| V6 Cryptography | no new requirements | UUID PKs are `gen_random_uuid()` (crypto-random) | + +### Threats + +| Threat | STRIDE | Mitigation | +|--------|--------|------------| +| Unauthorized tablo read/write | Elevation of privilege | `auth.Authed(ctx)` + `tablo.UserID != user.ID → http.NotFound` in every handler | +| CSRF on state-changing forms | Tampering | `@ui.CSRFField(csrfToken)` in every form; gorilla/csrf rejects missing token | +| Over-long title causes DB error | DoS/Data | `len(title) > 255` → 422 before DB write | +| UUID injection (non-UUID in {id}) | Tampering | `uuid.Parse(id)` returns error → 404 immediately |