docs(03): plan phase 3 — Tablos CRUD (3 plans, 3 waves)
Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration + sqlc + test scaffold + button CSS), list+create (dashboard, inline form, OOB swap), and detail+edit+delete (ownership 404, inline edit fragments, inline confirm delete). Includes Nyquist VALIDATION.md and PATTERNS.md with real analog excerpts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dc7f5ac4e2
commit
f53b54637b
8 changed files with 1777 additions and 9 deletions
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
313
.planning/phases/03-tablos-crud/03-01-PLAN.md
Normal file
313
.planning/phases/03-tablos-crud/03-01-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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
|
||||
</objective>
|
||||
|
||||
## 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.
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/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
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts that downstream plans (02, 03) will implement against. -->
|
||||
|
||||
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.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Migration 0003_tablos.sql + sqlc query file + regenerate sqlc</name>
|
||||
<files>backend/migrations/0003_tablos.sql, backend/internal/db/queries/tablos.sql, backend/internal/db/sqlc/tablos.sql.go, backend/internal/db/sqlc/models.go</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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 ./...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Migration committed, query file committed, sqlc-generated files committed, build green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: handlers_tablos_test.go — RED integration test scaffold for TABLO-01..06</name>
|
||||
<files>backend/internal/web/handlers_tablos_test.go</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
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 /.
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Test scaffold compiles; running `go test ./internal/web/... -run TestTablo` lists all 10 tests as FAIL (no implementation yet).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add ui-button-solid-danger-md and ui-button-soft-neutral-md CSS rules</name>
|
||||
<files>backend/internal/web/ui/button.css</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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).
|
||||
</acceptance_criteria>
|
||||
<done>CSS classes present, Tailwind regenerated, no other files modified.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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).
|
||||
</output>
|
||||
324
.planning/phases/03-tablos-crud/03-02-PLAN.md
Normal file
324
.planning/phases/03-tablos-crud/03-02-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-tablos-crud/03-CONTEXT.md
|
||||
@.planning/phases/03-tablos-crud/03-RESEARCH.md
|
||||
@.planning/phases/03-tablos-crud/03-PATTERNS.md
|
||||
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
|
||||
@.planning/phases/03-tablos-crud/03-01-SUMMARY.md
|
||||
@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
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts this plan establishes for Plan 03 -->
|
||||
|
||||
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 }
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: tablos.templ — dashboard, empty state, card, create form, OOB-clear (+ generate)</name>
|
||||
<files>backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go, backend/templates/layout.templ, backend/templates/layout_templ.go</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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".
|
||||
- `<div id="create-form-slot"></div>` rendered above `<div id="tablos-list">`.
|
||||
- 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 `<form id="create-form" method="POST" action="/tablos" hx-post="/tablos" hx-target="#create-form-slot" hx-swap="innerHTML">` 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 `<a href="/">Cancel</a>` 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 `<div id="create-form-slot" hx-swap-oob="true"></div>` (must be siblings, not nested per Pitfall 5).
|
||||
- layout.templ footer changes from "Phase 2 · Authentication" to "Phase 3 · Tablos".
|
||||
</behavior>
|
||||
<action>
|
||||
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 `<div class="tablo-delete-zone">` 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 `<div id="create-form-slot" hx-swap-oob="true"></div>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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 ./...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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 `<div id="create-form-slot" hx-swap-oob="true">` 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.
|
||||
</acceptance_criteria>
|
||||
<done>Templates compile, helper form/errors types declared, layout footer updated.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: handlers_tablos.go — TablosDeps + List/New/Create handlers + router/main wiring</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Dashboard + create slice green; create-form-slot OOB clear verified; non-HTMX 303 verified.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Human verify list + create flow</name>
|
||||
<what-built>
|
||||
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.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
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.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if the dashboard + create flow works as described, or describe issues.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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).
|
||||
</output>
|
||||
337
.planning/phases/03-tablos-crud/03-03-PLAN.md
Normal file
337
.planning/phases/03-tablos-crud/03-03-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Second vertical slice of Phase 3: detail page, inline edit (title + description), and inline-confirmation delete. Turns the remaining six TABLO tests green (TestTabloDetail_Owner, TestTabloDetail_NonOwner, TestTabloDetail_InvalidID, TestTabloUpdate, TestTabloDeleteConfirm, TestTabloDelete) and closes TABLO-03, TABLO-04, TABLO-05, TABLO-06.
|
||||
|
||||
Purpose: Complete the Tablos CRUD vertical so the user story shipped in Plan 02 (create + list) extends to read, update, and delete — all HTMX-driven with degradation to plain POST.
|
||||
|
||||
Output:
|
||||
- Detail page handler + ownership-enforced 404 path
|
||||
- Inline-edit GET/POST handlers for title and description (with discard-changes path)
|
||||
- Delete confirmation + delete handlers + cancel handler
|
||||
- All routes wired into the existing RequireAuth chi group in correct static-before-parametric order
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-tablos-crud/03-CONTEXT.md
|
||||
@.planning/phases/03-tablos-crud/03-RESEARCH.md
|
||||
@.planning/phases/03-tablos-crud/03-PATTERNS.md
|
||||
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
|
||||
@.planning/phases/03-tablos-crud/03-01-SUMMARY.md
|
||||
@.planning/phases/03-tablos-crud/03-02-SUMMARY.md
|
||||
@backend/internal/web/handlers_tablos.go
|
||||
@backend/internal/web/router.go
|
||||
@backend/templates/tablos.templ
|
||||
@backend/internal/db/sqlc/tablos.sql.go
|
||||
@backend/internal/auth/middleware.go
|
||||
|
||||
<interfaces>
|
||||
<!-- Handler & template contracts introduced by this plan -->
|
||||
|
||||
Handler constructors (all return http.HandlerFunc; signature pattern matches Plan 02):
|
||||
- TabloDetailHandler(deps TablosDeps) → GET /tablos/{id}
|
||||
- TabloEditTitleHandler(deps TablosDeps) → GET /tablos/{id}/edit-title
|
||||
- TabloShowTitleHandler(deps TablosDeps) → GET /tablos/{id}/show-title
|
||||
- TabloEditDescHandler(deps TablosDeps) → GET /tablos/{id}/edit-desc
|
||||
- TabloShowDescHandler(deps TablosDeps) → GET /tablos/{id}/show-desc
|
||||
- TabloUpdateHandler(deps TablosDeps) → POST /tablos/{id}
|
||||
- TabloDeleteConfirmHandler(deps TablosDeps) → GET /tablos/{id}/delete-confirm
|
||||
- TabloDeleteCancelHandler(deps TablosDeps) → GET /tablos/{id}/delete-cancel
|
||||
- TabloDeleteHandler(deps TablosDeps) → POST /tablos/{id}/delete
|
||||
|
||||
Templates added to tablos.templ:
|
||||
- TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo)
|
||||
- TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string)
|
||||
- TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
|
||||
- TabloDescDisplay(tablo sqlc.Tablo, csrfToken string)
|
||||
- TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
|
||||
- TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) -- the .tablo-delete-zone with the Delete button (same shape as in TabloCard, factored out)
|
||||
- TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string)
|
||||
- TabloNotFoundPage(user *auth.User, csrfToken string) -- 404 body with copy from UI-SPEC
|
||||
|
||||
Form types (extend tablos_forms.go):
|
||||
- TabloUpdateErrors { Title, Description, General string }
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: tablos.templ — detail page, edit fragments, delete fragments, 404 page (+ generate)</name>
|
||||
<files>backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go</files>
|
||||
<read_first>
|
||||
backend/templates/tablos.templ (current state from Plan 02)
|
||||
backend/templates/tablos_forms.go
|
||||
backend/templates/auth_form_errors.templ
|
||||
backend/templates/auth_signup.templ
|
||||
backend/internal/web/ui/button.templ
|
||||
backend/internal/web/ui/variants.go
|
||||
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contracts §3-§5, Component Inventory, Copywriting, HTMX Attribute Reference)
|
||||
.planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog blocks)
|
||||
.planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 2 fragment dispatch, Pattern 5 ownership 404)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TabloDetailPage wraps @Layout("Tablos — Xtablo", user, csrfToken) and inside the main container renders, in order: a back link to /, then a `.tablo-title-zone` div containing @TabloTitleDisplay, then a `.tablo-desc-zone` div containing @TabloDescDisplay, then a `.tablo-delete-zone` div containing @TabloDeleteButtonFragment.
|
||||
- TabloTitleDisplay renders the title as an `<h1 class="text-xl font-semibold leading-snug tablo-title-zone">` (the zone class on the SAME element so outerHTML swap replaces it cleanly) with hx-get="/tablos/{id}/edit-title", hx-target="closest .tablo-title-zone", hx-swap="outerHTML", role="button", aria-label="Edit title". Wraps with class "tablo-title-zone" — every Display/Edit fragment for the title shares this class so outerHTML round-trips work.
|
||||
- TabloTitleEditFragment renders a `<form class="tablo-title-zone" hx-post="/tablos/{id}" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" method="POST" action="/tablos/{id}">` with hidden _method=PATCH (semantic clarity per UI-SPEC §4) NOT consumed by chi but harmless, @ui.CSRFField, a labelled title input prefilled with tablo.Title, a hidden description field carrying current description value, @FieldError(errs.Title), submit button "Save changes", discard button with hx-get="/tablos/{id}/show-title" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" Variant Neutral Tone Soft.
|
||||
- TabloDescDisplay analogous to TabloTitleDisplay but element is a `<div class="tablo-desc-zone">` containing a `<p>` or empty-state hint "Add a description"; clicking fires hx-get="/tablos/{id}/edit-desc" hx-target="closest .tablo-desc-zone" hx-swap="outerHTML".
|
||||
- TabloDescEditFragment analogous to TabloTitleEditFragment but for the description textarea (3 rows) + hidden title field carrying current title; submit "Save changes" + discard "Discard changes" with hx-get="/tablos/{id}/show-desc".
|
||||
- TabloDeleteButtonFragment renders a `<div class="tablo-delete-zone">` wrapping a Delete button (Variant Danger Tone Soft Size MD) with hx-get="/tablos/{id}/delete-confirm", hx-target="closest .tablo-delete-zone", hx-swap="outerHTML", aria-label="Delete tablo".
|
||||
- TabloDeleteConfirmFragment renders a `<div class="tablo-delete-zone">` containing copy: "Delete tablo?" heading, "This cannot be undone." body, a "Yes, delete" submit button (Variant Danger Tone Solid Size MD, aria-label "Confirm delete tablo") inside a `<form method="POST" action="/tablos/{id}/delete" hx-post="/tablos/{id}/delete" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML">` with @ui.CSRFField, and a "Keep tablo" cancel button (Variant Neutral Tone Soft, aria-label "Keep tablo") with hx-get="/tablos/{id}/delete-cancel" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML".
|
||||
- TabloNotFoundPage wraps @Layout("Not found", user, csrfToken) and renders heading "Not found" + body "This tablo doesn't exist or you don't have access." (per UI-SPEC Copywriting). user may be nil if called from a path before RequireAuth runs — accept nil and forward to Layout.
|
||||
- tablos_forms.go gains `type TabloUpdateErrors struct { Title, Description, General string }`.
|
||||
- Re-use the dashboard's TabloCard delete-zone shape so TabloDeleteButtonFragment is the canonical implementation; update TabloCard from Plan 02 (if necessary) to call `@TabloDeleteButtonFragment(t, csrfToken)` instead of inlining the delete button — this guarantees a single source of truth for the zone HTML.
|
||||
</behavior>
|
||||
<action>
|
||||
Edit backend/templates/tablos.templ to add the eight new templ components listed above. Place them after the Plan 02 components. Use exact copywriting from UI-SPEC. Use ui.Button with Variant/Tone/Size enums (ButtonVariantDanger, ButtonVariantNeutral already exist; ButtonToneSoft exists per variants.go).
|
||||
|
||||
Append `type TabloUpdateErrors struct { Title, Description, General string }` to backend/templates/tablos_forms.go.
|
||||
|
||||
If TabloCard in Plan 02 inlined the delete button HTML, refactor it to call `@TabloDeleteButtonFragment(t, csrfToken)` — DO NOT duplicate the zone markup.
|
||||
|
||||
Use class attribute `tablo-title-zone`, `tablo-desc-zone`, and `tablo-delete-zone` ON the outermost element of each respective Display/Edit fragment so HTMX's `hx-target="closest .tablo-X-zone"` + `hx-swap="outerHTML"` round-trips replace the zone wholesale.
|
||||
|
||||
Pitfall 5 reminder: no nested OOB swaps required in this plan, but the zone elements must NOT be wrapped in extra containers that the swap target would miss.
|
||||
|
||||
Run `just generate` to produce backend/templates/tablos_templ.go.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && just generate && grep -q "templ TabloDetailPage" templates/tablos.templ && grep -q "templ TabloTitleDisplay" templates/tablos.templ && grep -q "templ TabloTitleEditFragment" templates/tablos.templ && grep -q "templ TabloDescDisplay" templates/tablos.templ && grep -q "templ TabloDescEditFragment" templates/tablos.templ && grep -q "templ TabloDeleteButtonFragment" templates/tablos.templ && grep -q "templ TabloDeleteConfirmFragment" templates/tablos.templ && grep -q "templ TabloNotFoundPage" templates/tablos.templ && grep -q "Delete tablo?" templates/tablos.templ && grep -q "This cannot be undone." templates/tablos.templ && grep -q "Yes, delete" templates/tablos.templ && grep -q "Keep tablo" templates/tablos.templ && grep -q "Save changes" templates/tablos.templ && grep -q "Discard changes" templates/tablos.templ && grep -q "tablo-title-zone" templates/tablos.templ && grep -q "tablo-desc-zone" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "This tablo doesn't exist or you don't have access." templates/tablos.templ && grep -q "type TabloUpdateErrors" templates/tablos_forms.go && go build ./...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- tablos.templ contains all 8 new templ component declarations exactly as named in the interfaces block.
|
||||
- File contains exact UI-SPEC copy: "Delete tablo?", "This cannot be undone.", "Yes, delete", "Keep tablo", "Save changes", "Discard changes", "Not found", "This tablo doesn't exist or you don't have access.".
|
||||
- Each of `tablo-title-zone`, `tablo-desc-zone`, `tablo-delete-zone` appears as a CSS class on at least TWO different templ components (Display + Edit, or Button + Confirm) — verified by `grep -c "class=\"tablo-title-zone\"" templates/tablos.templ` >= 2 (same for desc and delete zones).
|
||||
- aria-label values "Edit title", "Confirm delete tablo", "Keep tablo", "Delete tablo" all present.
|
||||
- hx-target uses `closest .tablo-{title,desc,delete}-zone` in every relevant fragment (not absolute selectors) so the same template works from card or detail-page contexts.
|
||||
- tablos_forms.go declares `type TabloUpdateErrors struct` with Title, Description, General string fields.
|
||||
- TabloCard delegates delete-button rendering to TabloDeleteButtonFragment (no inline duplication of the delete-zone HTML).
|
||||
- `just generate` produced an updated tablos_templ.go.
|
||||
- `go build ./...` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>All Phase 3 templates declared, generated, and compile.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: handlers_tablos.go — detail + edit + delete handlers + router wiring</name>
|
||||
<files>backend/internal/web/handlers_tablos.go, backend/internal/web/router.go</files>
|
||||
<read_first>
|
||||
backend/internal/web/handlers_tablos.go (current state from Plan 02)
|
||||
backend/internal/web/router.go (current state from Plan 02)
|
||||
backend/internal/web/handlers_auth.go (HTMX-aware redirect, fragment dispatch)
|
||||
backend/internal/auth/middleware.go (Authed, redirectTo)
|
||||
backend/internal/db/sqlc/tablos.sql.go (UpdateTablo signature)
|
||||
backend/internal/web/handlers_tablos_test.go (RED tests to turn green)
|
||||
.planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 3, 5, 6, 7, 8 + Pitfalls 1, 6, 7)
|
||||
.planning/phases/03-tablos-crud/03-PATTERNS.md (UUID extraction, ownership check, HTMX redirect)
|
||||
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §3-§5)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Common pattern shared by every /tablos/{id}* handler:
|
||||
1. `_, user, _ := auth.Authed(r.Context())`
|
||||
2. Parse id: `tabloID, err := uuid.Parse(chi.URLParam(r, "id"))`. err != nil → http.NotFound(w, r); return.
|
||||
3. Fetch: `tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)`. errors.Is(err, pgx.ErrNoRows) → http.NotFound. Other errors → 500.
|
||||
4. Ownership: `if tablo.UserID != user.ID { http.NotFound(w, r); return }` (D-04: 404 not 403).
|
||||
|
||||
TabloDetailHandler (GET /tablos/{id}):
|
||||
- After common steps, render TabloDetailPage(user, csrf.Token(r), tablo). Content-Type text/html; charset=utf-8.
|
||||
|
||||
TabloEditTitleHandler / TabloEditDescHandler (GET /tablos/{id}/edit-title|edit-desc):
|
||||
- After common steps, render TabloTitleEditFragment / TabloDescEditFragment with empty TabloUpdateErrors.
|
||||
|
||||
TabloShowTitleHandler / TabloShowDescHandler (GET /tablos/{id}/show-title|show-desc):
|
||||
- After common steps, render TabloTitleDisplay / TabloDescDisplay (used by discard-changes path).
|
||||
|
||||
TabloUpdateHandler (POST /tablos/{id}):
|
||||
- After common steps, read r.PostFormValue("title") and r.PostFormValue("description"). Trim title.
|
||||
- Validate: title required → "Title is required."; len(title) > 255 → "Title must be 255 characters or fewer.".
|
||||
- On validation error: status 422 + render TabloTitleEditFragment (if r.PostFormValue("title") was the changed field) OR more simply re-render the detail page with errs surfaced. Pragmatic choice: render TabloTitleEditFragment with errs when HX-Request, else 422 + TabloDetailPage with errs.General. The test TestTabloUpdate only asserts happy path + DB update, so keep error path consistent with handlers_auth.go style.
|
||||
- On success: call deps.Queries.UpdateTablo(ctx, UpdateTabloParams{ID: tabloID, Title: title, Description: pgtype.Text{Valid: description != "", String: description}}). Render TabloTitleDisplay (if title changed) OR TabloDescDisplay (if description changed) based on which field came through the form; simpler: detect which field was non-empty in the request and render the matching display fragment. Cleaner: since the title edit form submits BOTH title and description (description as hidden field) and vice versa, the handler always updates both and the caller's hx-target tells HTMX which zone to swap. Choose the cleaner approach — both fields always present; render the TabloTitleDisplay when r.PostFormValue("_zone") == "title" else TabloDescDisplay. To avoid the _zone hidden field complexity, render only TabloTitleDisplay for now and let TabloDescDisplay come on description-edit through a separate handler — but spec says one POST endpoint. Final decision: pass a `_zone` hidden input ("title" or "desc") in each edit fragment; handler reads it and renders the matching display. Non-HTMX path → 303 to /tablos/{id}.
|
||||
- On success (HX-Request): 200 + display fragment in body. Non-HTMX: 303 to /tablos/{id}.
|
||||
|
||||
TabloDeleteConfirmHandler (GET /tablos/{id}/delete-confirm):
|
||||
- After common steps, render TabloDeleteConfirmFragment(tablo, csrfToken).
|
||||
|
||||
TabloDeleteCancelHandler (GET /tablos/{id}/delete-cancel):
|
||||
- After common steps, render TabloDeleteButtonFragment(tablo, csrfToken).
|
||||
|
||||
TabloDeleteHandler (POST /tablos/{id}/delete):
|
||||
- After common steps, call deps.Queries.DeleteTablo(ctx, tabloID).
|
||||
- HTMX path: 200 + HX-Redirect: / header. Non-HTMX path: 303 to /.
|
||||
- Detail-page delete (HX-Request from detail page): same HX-Redirect: / behavior — the test TestTabloDelete checks for either deletion + redirect; document and pick one.
|
||||
|
||||
Router wiring (extend the protected group from Plan 02; MUST be added AFTER `r.Get("/tablos/new", ...)` and `r.Post("/tablos", ...)`):
|
||||
- r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
|
||||
- r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
|
||||
- r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
|
||||
- r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
|
||||
- r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
|
||||
- r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
|
||||
- r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
|
||||
- r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
|
||||
- r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
|
||||
|
||||
Pitfall 1: static `/tablos/new` already declared before parametric `/tablos/{id}` per Plan 02 ordering; verify by reading router.go before editing.
|
||||
</behavior>
|
||||
<action>
|
||||
Append nine handler constructors to backend/internal/web/handlers_tablos.go per the behavior block. Each constructor returns http.HandlerFunc closing over deps. Factor the common preamble (Authed extract, uuid.Parse, GetTabloByID, ownership check) into a private helper `loadOwnedTablo(w, r, deps) (sqlc.Tablo, *auth.User, bool)` — returns false when any check fails (the helper has already written the 404/500 response). All nine specific-tablo handlers call this helper first.
|
||||
|
||||
Use imports: "github.com/go-chi/chi/v5", "github.com/google/uuid", "errors", "github.com/jackc/pgx/v5", "github.com/jackc/pgx/v5/pgtype". csrf token via `csrf.Token(r)`. PostFormValue for title/description (Pitfall 2). pgtype.Text{Valid: s != "", String: s} for nullable description.
|
||||
|
||||
For TabloUpdateHandler: emit `<input type="hidden" name="_zone" value="title">` or `..."desc"` from the respective edit fragment template (Task 1 must already do this — if not, fix Task 1's templates to add the hidden _zone field). Handler reads `zone := r.PostFormValue("_zone")` and renders TabloTitleDisplay if zone == "title", TabloDescDisplay if zone == "desc", else default to TabloTitleDisplay. Non-HTMX path: 303 redirect to /tablos/{id} (full page reload renders the new state).
|
||||
|
||||
For TabloDeleteHandler HTMX response: `w.Header().Set("HX-Redirect", "/"); w.WriteHeader(http.StatusOK)`. Non-HTMX: `http.Redirect(w, r, "/", http.StatusSeeOther)`.
|
||||
|
||||
Edit backend/internal/web/router.go: append the nine new routes inside the protected group AFTER the existing `r.Post("/tablos", ...)` line and BEFORE the closing `})`. Order: `r.Get("/tablos/{id}", ...)`, `r.Post("/tablos/{id}", ...)`, then the six sub-route GETs, then `r.Post("/tablos/{id}/delete", ...)`.
|
||||
|
||||
After edits run `just test`. All 10 TABLO tests must be green. Phase 1/2 tests must remain green.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && just generate && grep -q "func TabloDetailHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloUpdateHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteConfirmHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteCancelHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteHandler" internal/web/handlers_tablos.go && grep -q "uuid.Parse(chi.URLParam" internal/web/handlers_tablos.go && grep -q "tablo.UserID != user.ID" internal/web/handlers_tablos.go && grep -q "http.NotFound" internal/web/handlers_tablos.go && grep -q "HX-Redirect" internal/web/handlers_tablos.go && grep -q "deps.Queries.UpdateTablo" internal/web/handlers_tablos.go && grep -q "deps.Queries.DeleteTablo" internal/web/handlers_tablos.go && grep -q "r.Get(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Get(\"/tablos/{id}/edit-title\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}/delete\"" internal/web/router.go && awk '/r.Get\("\/tablos\/new"/ {newln=NR} /r.Get\("\/tablos\/\{id\}"/ {idln=NR} END {exit !(newln>0 && idln>0 && newln<idln)}' internal/web/router.go && go build ./... && go test ./internal/web/... -run TestTablo -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- handlers_tablos.go declares all nine new handler functions (signatures match the interfaces block).
|
||||
- handlers_tablos.go contains a single ownership-and-load helper used by all nine new handlers (grep "loadOwnedTablo" or equivalent appears >=2 occurrences: the declaration + at least one caller).
|
||||
- Every specific-tablo handler returns 404 on (a) uuid.Parse error, (b) pgx.ErrNoRows, (c) UserID mismatch — verified by passing TestTabloDetail_NonOwner and TestTabloDetail_InvalidID.
|
||||
- TabloUpdateHandler calls deps.Queries.UpdateTablo with the parsed ID and trimmed title; pgtype.Text used for description.
|
||||
- TabloDeleteHandler calls deps.Queries.DeleteTablo and sets HX-Redirect: / on HTMX path, 303 to / on non-HTMX path.
|
||||
- router.go contains the nine new routes inside the existing RequireAuth group. The line declaring `r.Get("/tablos/new", ...)` appears BEFORE the line declaring `r.Get("/tablos/{id}", ...)` (Pitfall 1) — verified by the awk check in the verify block.
|
||||
- `go build ./...` exits 0.
|
||||
- `go test ./internal/web/... -run TestTablo -count=1` exits 0 — all 10 named TABLO tests pass.
|
||||
- Phase 1/2 regression: `go test ./internal/web/...` exits 0 across all tests (no broken auth/signup/csrf tests).
|
||||
</acceptance_criteria>
|
||||
<done>Detail + edit + delete slice green; all 10 TABLO tests pass; static-before-parametric order verified.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Human verify detail + edit + delete + ownership 404</name>
|
||||
<what-built>
|
||||
Tablo detail page with inline edit for title and description, and inline-confirmation delete. Non-owner and bad-UUID requests return 404. HTMX paths swap fragments without full reloads; non-HTMX paths POST and redirect cleanly.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. cd backend && just dev.
|
||||
2. Log in as user A. Create two tablos.
|
||||
3. Click "View" on a tablo card → detail page renders with title and description.
|
||||
4. Click the title → input appears in place; change text; click "Save changes" — display swaps back to the new title without full reload. Click "Discard changes" instead to confirm it restores the original.
|
||||
5. Repeat for description.
|
||||
6. From the detail page, click "Delete" → inline confirmation "Delete tablo? This cannot be undone." [Yes, delete] [Keep tablo] appears in place.
|
||||
7. Click "Keep tablo" → confirmation collapses, Delete button restored.
|
||||
8. Click "Delete" → "Yes, delete" → page navigates to / (HX-Redirect); tablo is gone from list.
|
||||
9. Repeat delete from dashboard card — confirmation appears inside the card's delete-zone, "Yes, delete" removes the card from the list.
|
||||
10. Ownership test: open a private window, sign up as user B. Try GET /tablos/{user-A-tablo-uuid} → 404 page with copy "This tablo doesn't exist or you don't have access.".
|
||||
11. Bad UUID test: GET /tablos/not-a-uuid → 404.
|
||||
12. CSRF test: view-source the detail page; confirm `_csrf` hidden input is rendered in every form (edit-title form, edit-desc form, delete-confirm form).
|
||||
13. Non-JS fallback: disable JavaScript; from detail page submit edit form via the actual Submit button — page reloads to /tablos/{id} with updated content. Submit delete via the actual button — redirects to /.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all 13 checks pass, or describe issues.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /tablos/{id}* | URL parameter (UUID) is untrusted; must be parsed and ownership-checked on every request. |
|
||||
| Browser → POST /tablos/{id} | Untrusted form input (title, description, _zone, _csrf). |
|
||||
| Browser → POST /tablos/{id}/delete | Authenticated state-changing request guarded by CSRF token. |
|
||||
| Handler → Postgres | Parameterised via sqlc UpdateTabloParams / DeleteTablo($1). |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-03-01 | Elevation of privilege | Cross-tenant tablo access | mitigate | loadOwnedTablo helper enforces tablo.UserID == user.ID; non-owner gets http.NotFound (D-04: 404 not 403 to avoid existence leak). Verified by TestTabloDetail_NonOwner. |
|
||||
| T-03-03-02 | Information disclosure | UUID enumeration | mitigate | UUIDs are gen_random_uuid (crypto-random) + 404 response for non-owner masks existence. |
|
||||
| T-03-03-03 | Tampering | UUID injection / path traversal via {id} | mitigate | uuid.Parse rejects non-UUID strings → http.NotFound before any DB query. Verified by TestTabloDetail_InvalidID. |
|
||||
| T-03-03-04 | Tampering | CSRF on POST /tablos/{id} and /tablos/{id}/delete | mitigate | gorilla/csrf middleware validates _csrf field; @ui.CSRFField rendered inside every form template (edit-title, edit-desc, delete-confirm). |
|
||||
| T-03-03-05 | DoS | over-long title on edit | mitigate | TabloUpdateHandler validates len(title) <= 255 with explicit 422 + field error before DB write. |
|
||||
| T-03-03-06 | XSS | reflected user content in title/description on detail page and edit fragments | mitigate | templ auto-escapes `{ tablo.Title }`, `{ tablo.Description.String }`. No templ.Raw. Inline color style attribute also escaped. |
|
||||
| T-03-03-07 | Repudiation | edit/delete not logged | accept | v1 scope — created_at/updated_at on the row; no audit table planned for v1. |
|
||||
| T-03-03-08 | Tampering | _zone hidden field abuse | accept | _zone selects which display fragment to render; misuse only affects the response body shape, not DB state — DB UPDATE always uses authenticated user's row regardless of _zone value. |
|
||||
| T-03-03-09 | Spoofing | Delete bypass via direct POST | mitigate | RequireAuth middleware + loadOwnedTablo helper run on /tablos/{id}/delete; unauthed → /login, non-owner → 404. CSRF middleware also blocks cross-origin POSTs. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- All 10 TABLO tests pass: `go test ./internal/web/... -run TestTablo -count=1` exits 0.
|
||||
- Phase 1/2 regression suite still green: `go test ./internal/web/...` exits 0.
|
||||
- Manual browser verification (Task 3 checkpoint) passes all 13 sub-checks, including ownership 404 + bad-UUID 404 + non-JS fallbacks.
|
||||
- Static-before-parametric route ordering verified in router.go (Pitfall 1).
|
||||
- `grep -c "http.NotFound" backend/internal/web/handlers_tablos.go` >= 3 (uuid parse failure + pgx.ErrNoRows + ownership mismatch).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. TABLO-03: GET /tablos/{id} renders detail page for owner; non-owner and malformed UUID get 404.
|
||||
2. TABLO-04: Inline edit of title and description updates the row, refreshes the affected fragment, and sets updated_at = now() (Pitfall 7).
|
||||
3. TABLO-05: Inline confirmation gates delete; confirming hard-deletes the row and redirects to /; cancel restores the original button.
|
||||
4. TABLO-06: Every mutating route works under both HTMX and non-HTMX clients (303 fallback verified manually).
|
||||
5. All ten TABLO tests in handlers_tablos_test.go are green.
|
||||
6. No Phase 1/2 regressions.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-tablos-crud/03-03-SUMMARY.md` documenting: nine new handler functions and the loadOwnedTablo helper, eight new templ components, nine new router lines (in declared order), final TABLO test status (10/10 green), and notes on the _zone hidden-field approach used for the unified POST /tablos/{id} update endpoint.
|
||||
</output>
|
||||
702
.planning/phases/03-tablos-crud/03-PATTERNS.md
Normal file
702
.planning/phases/03-tablos-crud/03-PATTERNS.md
Normal file
|
|
@ -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) {
|
||||
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
||||
@ui.Card(nil) {
|
||||
<div class="w-full max-w-sm px-6 py-8">
|
||||
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
|
||||
@SignupFormFragment(form, errs, csrfToken)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fragment component with HTMX form** (auth_signup.templ lines 25-72):
|
||||
```go
|
||||
templ SignupFormFragment(form SignupForm, errs SignupErrors, csrfToken string) {
|
||||
<form
|
||||
id="signup-form"
|
||||
method="POST"
|
||||
action="/signup"
|
||||
hx-post="/signup"
|
||||
hx-target="#signup-form"
|
||||
hx-swap="outerHTML"
|
||||
class="space-y-5"
|
||||
>
|
||||
@ui.CSRFField(csrfToken)
|
||||
@GeneralError(errs.General)
|
||||
...
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Create account",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "submit",
|
||||
})
|
||||
</form>
|
||||
}
|
||||
```
|
||||
|
||||
**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 != "" {
|
||||
<p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
|
||||
}
|
||||
```
|
||||
|
||||
**FieldError and GeneralError** (auth_form_errors.templ lines 5-19):
|
||||
```go
|
||||
templ FieldError(msg string) {
|
||||
if msg != "" {
|
||||
<p class="mt-1 text-sm text-red-700">{ msg }</p>
|
||||
}
|
||||
}
|
||||
|
||||
templ GeneralError(msg string) {
|
||||
if msg != "" {
|
||||
<div class="mb-4 rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{ msg }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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)
|
||||
<div id="create-form-slot" hx-swap-oob="true"></div>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `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
|
||||
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
||||
Phase 2 · Authentication
|
||||
</footer>
|
||||
```
|
||||
|
||||
**Change to:**
|
||||
```go
|
||||
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
|
||||
Phase 3 · Tablos
|
||||
</footer>
|
||||
```
|
||||
|
||||
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 `<form method="POST">` in `tablos.templ`
|
||||
|
||||
```go
|
||||
templ CSRFField(token string) {
|
||||
<input type="hidden" name="_csrf" value={ token }/>
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
85
.planning/phases/03-tablos-crud/03-VALIDATION.md
Normal file
85
.planning/phases/03-tablos-crud/03-VALIDATION.md
Normal file
|
|
@ -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 |
|
||||
Loading…
Reference in a new issue