docs(12): create phase plan

This commit is contained in:
Arthur Belleville 2026-05-16 10:04:06 +02:00
parent 164fa1133c
commit f848c42e54
No known key found for this signature in database
7 changed files with 792 additions and 11 deletions

View file

@ -2,15 +2,15 @@
gsd_state_version: 1.0
milestone: v2.0
milestone_name: Collaboration, planning, and social sign-in
status: planning
last_updated: "2026-05-16T07:55:44.531Z"
last_activity: 2026-05-16
status: executing
last_updated: "2026-05-16T08:03:11.927Z"
last_activity: 2026-05-16 -- Phase 12 planning complete
progress:
total_phases: 5
completed_phases: 4
total_plans: 15
total_plans: 18
completed_plans: 15
percent: 100
percent: 83
---
# STATE
@ -24,15 +24,15 @@ progress:
See: `.planning/PROJECT.md` (updated 2026-05-15)
**Core value:** A user can sign in and run the Tablos workflow — organize work, attach files, discuss, and plan scheduled events — without a JS framework or managed chat provider.
**Current focus:** Phase 11 — Individual Planning
**Current focus:** Phase 12 — Native Tablo Chat
## Current Position
Phase: 12
Plan: Not started
Status: Ready to plan
Last activity: 2026-05-16
Resume file: .planning/phases/12-native-tablo-chat/12-UI-SPEC.md
Status: Ready to execute
Last activity: 2026-05-16 -- Phase 12 planning complete
Resume file: .planning/phases/12-native-tablo-chat/12-01-PLAN.md
## Phase Status
@ -41,8 +41,8 @@ Resume file: .planning/phases/12-native-tablo-chat/12-UI-SPEC.md
| 8 | Social Sign-in | ✓ Complete |
| 9 | Etapes | ◆ UAT passed; security pending |
| 10 | Events | ✓ Complete |
| 11 | Individual Planning | ◆ Ready to execute |
| 12 | Native Tablo Chat | ○ Pending |
| 11 | Individual Planning | ✓ Complete |
| 12 | Native Tablo Chat | ◆ Ready to execute |
## Verification Record

View file

@ -0,0 +1,170 @@
---
phase: 12-native-tablo-chat
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/migrations/0009_discussion.sql
- backend/internal/db/queries/discussion.sql
- backend/internal/web/handlers_discussion_test.go
- backend/internal/web/handlers_discussion.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
- backend/templates/tablos.templ
- backend/templates/discussion.templ
- backend/templates/discussion_forms.go
autonomous: true
requirements: [CHAT-01, CHAT-02, CHAT-03, CHAT-06]
must_haves:
truths:
- "D-01/D-02/D-03/D-04: Discussion is a fifth tablo detail tab after Events, with direct full-page fallback."
- "D-06/D-07/D-08/D-09/D-10: rows show author, absolute timestamp, text, oldest first, day separators, bottom composer, and specified empty state."
- "D-11/D-12/D-13/D-14: schema carries nullable edit/delete metadata; no edit/delete UI appears."
- "D-15/D-16: composer is textarea plus Send message; no Enter-to-send behavior."
- "D-22/D-23/D-24/D-25: owner-only; author label uses email fallback; header may show 1 participant."
- "CHAT-06: message body is length-limited server-side and rendered escaped through templ."
artifacts:
- path: "backend/migrations/0009_discussion.sql"
provides: "message and read-state schema foundation"
- path: "backend/internal/db/queries/discussion.sql"
provides: "typed sqlc discussion history/send/read queries"
- path: "backend/internal/web/handlers_discussion.go"
provides: "protected discussion tab and send handlers"
- path: "backend/templates/discussion.templ"
provides: "server-rendered discussion UI"
---
<objective>
Vertical slice 1: add persisted discussion history and CSRF-protected message posting inside the tablo detail Discussion tab, without realtime or dashboard unread badges yet.
</objective>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-PATTERNS.md
</context>
<threat_model>
T-12-01 Schema bypass: message rows must reference real tablos/users with cascading foreign keys.
T-12-02 Future lifecycle ambiguity: include nullable edit/delete metadata but do not expose edit/delete UI or decide tombstone copy.
T-12-03 Unauthenticated access: discussion routes must live inside `auth.RequireAuth`.
T-12-04 Cross-user leak/mutation: all handlers must call `loadOwnedTablo`; non-owner access returns 404.
T-12-05 XSS/body abuse: body is trimmed, non-empty, max-length checked, and rendered through templ escaped expressions.
T-12-06 CSRF bypass: message send remains normal POST with gorilla/csrf hidden token.
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: Add discussion schema and sqlc queries</name>
<files>
- backend/migrations/0009_discussion.sql
- backend/internal/db/queries/discussion.sql
</files>
<read_first>
- backend/migrations/0003_tablos.sql
- backend/migrations/0008_events.sql
- backend/internal/db/queries/tablos.sql
- backend/internal/db/queries/events.sql
- .planning/phases/12-native-tablo-chat/12-RESEARCH.md
</read_first>
<action>
Create `discussion_messages` with `id uuid default gen_random_uuid()`, `tablo_id uuid not null references tablos(id) on delete cascade`, `author_user_id uuid not null references users(id) on delete restrict`, `body text not null`, `created_at timestamptz not null default now()`, `updated_at timestamptz not null default now()`, nullable `edited_at`, `edited_by_user_id`, `deleted_at`, and `deleted_by_user_id`. Add checks for non-blank trimmed body and maximum body length; use 10000 characters unless implementation finds a stronger local convention.
Create `discussion_read_states` keyed by `(tablo_id, user_id)` with nullable `last_read_message_id`, `last_read_at timestamptz not null default now()`, `created_at`, and `updated_at`.
Add indexes for `(tablo_id, created_at, id)` and read-state lookup. Add sqlc queries for list history oldest-first with author email, insert message, get message row with author email, and upsert read state.
</action>
<verify>
<automated>cd backend && just generate</automated>
</verify>
<acceptance_criteria>
- Migration includes message lifecycle metadata fields required by CHAT-03.
- Migration includes persistent read-state table for later D-05/D-26 work.
- `discussion.sql` exposes typed queries needed by history and send handlers.
- `cd backend && just generate` exits 0.
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Add RED discussion handler tests</name>
<files>
- backend/internal/web/handlers_discussion_test.go
</files>
<read_first>
- backend/internal/web/handlers_events_test.go
- backend/internal/web/handlers_tablos_test.go
- backend/internal/web/router.go
- .planning/phases/12-native-tablo-chat/12-UI-SPEC.md
</read_first>
<action>
Add DB-backed full-router tests that initially fail before implementation:
1. `TestDiscussionTabRendersHistoryAndComposer` creates two messages with HTML-looking body text, requests `GET /tablos/{id}/discussion`, and expects Discussion, `1 participant`, oldest-first ordering, author email, absolute timestamp content, escaped text, day separator, textarea `Message`, placeholder `Write a message...`, and `Send message`.
2. `TestDiscussionTabFullPageFallback` sends a non-HTMX direct GET and expects the full tablo detail shell with Discussion active.
3. `TestDiscussionPostCreatesMessage` obtains CSRF, posts a valid body with `HX-Request:true`, expects 200, the new message fragment, and a DB row for the authenticated author/tablo.
4. `TestDiscussionPostRejectsEmptyAndTooLong` expects 422 plus `Message is required.` and `Message is too long.`.
5. `TestDiscussionOwnershipReturns404` verifies another user cannot GET or POST to the owner's discussion.
6. `TestDiscussionRequiresCSRF` posts without CSRF and expects gorilla/csrf rejection.
</action>
<verify>
<automated>cd backend && go test ./internal/web -run 'TestDiscussion' -count=1</automated>
</verify>
<acceptance_criteria>
- Tests use the existing session/CSRF/full-router helper patterns.
- Tests prove body escaping by asserting raw `<script>` is absent and escaped text appears.
- Tests assert no edit/delete controls appear in the Discussion tab.
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Implement discussion tab, templates, and POST send</name>
<files>
- backend/internal/web/handlers_discussion.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
- backend/templates/tablos.templ
- backend/templates/discussion.templ
- backend/templates/discussion_forms.go
</files>
<read_first>
- backend/internal/web/handlers_events.go
- backend/templates/events.templ
- backend/templates/tablos.templ
- backend/templates/auth_form_errors.templ
- backend/internal/web/router.go
- .planning/phases/12-native-tablo-chat/12-PATTERNS.md
</read_first>
<action>
Add `DiscussionDeps{Queries *sqlc.Queries}` and route wiring in `NewRouter` and `cmd/web/main.go`. Mount `GET /tablos/{id}/discussion` and `POST /tablos/{id}/discussion/messages` inside the protected route group after Events static routes and before later parametric child routes.
Add `TabloDiscussionTabHandler` using `loadOwnedTablo`, `ListDiscussionMessagesByTablo`, and read-state upsert. Return `DiscussionTabFragment` for HTMX and `TabloDetailPage(..., activeTab="discussion")` for full-page fallback.
Add `DiscussionMessageCreateHandler` that trims body, rejects whitespace-only and max-length violations, inserts with the authenticated user ID, marks sender read state, and renders a message fragment for HTMX. For non-HTMX valid POST, redirect to `/tablos/{id}/discussion` with 303.
Extend `TabloDetailPage` signature carefully for discussion data or a view model; update all call sites. Add Discussion tab link after Events and render `DiscussionTabFragment` when `activeTab == "discussion"`.
Add templates for heading, participant count, day separators, message rows, empty state, and composer following `12-UI-SPEC.md`.
</action>
<verify>
<automated>cd backend && just generate && go test ./internal/web -run 'TestDiscussion' -count=1</automated>
</verify>
<acceptance_criteria>
- `GET /tablos/{id}/discussion` works as HTMX fragment and full page.
- `POST /tablos/{id}/discussion/messages` is CSRF-protected, validates body, persists message, and renders sender message immediately.
- Discussion UI contains no edit/delete/reaction/reply/presence/typing/attachment controls.
- Targeted `TestDiscussion...` tests pass.
</acceptance_criteria>
</task>
</tasks>
<verification>
Run:
- `cd backend && just generate`
- `cd backend && go test ./internal/web -run 'TestDiscussion' -count=1`
- `git diff --check`
</verification>
<success_criteria>
- CHAT-01: authorized users can load persisted discussion history.
- CHAT-02: users can post text messages with CSRF and validation.
- CHAT-03: message schema includes required core and lifecycle metadata.
- CHAT-06: message body is max-length checked and escaped in rendered HTML.
</success_criteria>

View file

@ -0,0 +1,129 @@
---
phase: 12-native-tablo-chat
plan: 02
type: execute
wave: 2
depends_on:
- 12-01-PLAN.md
files_modified:
- backend/internal/db/queries/tablos.sql
- backend/internal/db/queries/discussion.sql
- backend/internal/web/handlers_tablos.go
- backend/internal/web/handlers_discussion.go
- backend/internal/web/handlers_discussion_test.go
- backend/templates/tablos.templ
- backend/templates/discussion_forms.go
autonomous: true
requirements: [CHAT-01]
must_haves:
truths:
- "D-05/D-26: dashboard tablo cards show durable unread badges from persistent per-user/tablo read state."
- "D-19: unread-badge updates must be computable for later SSE refreshes."
- "D-22: unread queries remain owner-only under current tablo ownership."
- "Unread badges are numeric only and omitted when count is zero."
artifacts:
- path: "backend/internal/db/queries/discussion.sql"
provides: "mark-read and unread count queries"
- path: "backend/internal/db/queries/tablos.sql"
provides: "dashboard tablo rows with unread counts or a companion query"
- path: "backend/templates/tablos.templ"
provides: "small dashboard unread badge"
---
<objective>
Vertical slice 2: wire persistent read state and dashboard unread badges so the discussion surface has durable unread behavior before realtime events are added.
</objective>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-01-PLAN.md
</context>
<threat_model>
T-12-07 Cross-user unread leak: unread counts must be computed only for tablos owned by the authenticated user.
T-12-08 Stale badges: opening a discussion must mark messages read for that user/tablo so dashboard counts clear after reload.
T-12-09 Layout regression: unread badge must not overlap title/color/delete controls or resize cards unpredictably.
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: Add RED unread badge and read-state tests</name>
<files>
- backend/internal/web/handlers_discussion_test.go
</files>
<read_first>
- backend/internal/web/handlers_events_test.go
- backend/internal/web/handlers_tablos.go
- backend/templates/tablos.templ
- .planning/phases/12-native-tablo-chat/12-UI-SPEC.md
</read_first>
<action>
Add tests that initially fail:
1. `TestTablosListDiscussionUnreadBadge` creates two tablos, messages in one unread discussion, and expects `/` to show a numeric badge with accessible label such as `3 unread discussion messages` only on the matching card.
2. `TestTablosListDiscussionUnreadDoesNotLeakOtherUsers` creates another user's messages and expects they do not affect the authenticated user's dashboard.
3. `TestDiscussionGetMarksMessagesRead` creates unread messages, opens `/tablos/{id}/discussion`, then expects `/` no longer shows that unread badge.
4. `TestDiscussionPostMarksSenderRead` posts as the authenticated user and verifies the sender does not see their own message as unread after redirect/dashboard reload.
</action>
<verify>
<automated>cd backend && go test ./internal/web -run 'TestTablosListDiscussionUnread|TestDiscussion.*Read' -count=1</automated>
</verify>
<acceptance_criteria>
- Tests prove unread counts are scoped to the authenticated owner.
- Tests prove reading the discussion clears the badge through persistent state.
- Tests prove sender-created messages are not counted unread for the sender.
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Implement unread query integration and dashboard badge</name>
<files>
- backend/internal/db/queries/discussion.sql
- backend/internal/db/queries/tablos.sql
- backend/internal/web/handlers_tablos.go
- backend/internal/web/handlers_discussion.go
- backend/templates/tablos.templ
- backend/templates/discussion_forms.go
</files>
<read_first>
- backend/internal/db/queries/tablos.sql
- backend/internal/db/queries/discussion.sql
- backend/internal/web/handlers_tablos.go
- backend/templates/tablos.templ
- .planning/phases/12-native-tablo-chat/12-PATTERNS.md
</read_first>
<action>
Add sqlc support for unread counts. Prefer either `ListTablosByUserWithDiscussionUnread` or a companion query keyed by `user_id`; avoid N+1 per-card queries if practical.
Update `TablosListHandler` and create-tablo HTMX paths to pass a dashboard view model that includes unread counts. If a newly created tablo has no unread state, badge count is zero and omitted.
Add a `DiscussionUnreadBadge` template/helper that renders numeric copy only when count > 0, caps display at `99+` if needed, and includes an accessible label.
Ensure `TabloDiscussionTabHandler` marks messages read after successfully loading history, and `DiscussionMessageCreateHandler` marks the sender's read state after insert.
Run `cd backend && just generate`.
</action>
<verify>
<automated>cd backend && just generate && go test ./internal/web -run 'TestTablosListDiscussionUnread|TestDiscussion.*Read' -count=1</automated>
</verify>
<acceptance_criteria>
- Dashboard cards show unread badges only when count > 0.
- Opening Discussion clears the current user's unread badge durably.
- Sender's own posts do not create unread count for the sender.
- Targeted unread/read-state tests pass.
</acceptance_criteria>
</task>
</tasks>
<verification>
Run:
- `cd backend && just generate`
- `cd backend && go test ./internal/web -run 'TestTablosListDiscussionUnread|TestDiscussion.*Read' -count=1`
- `git diff --check`
</verification>
<success_criteria>
- D-05/D-26 are satisfied with persistent unread/read-state support.
- Dashboard badge behavior is ready for later SSE OOB refresh.
- Existing dashboard tablos behavior still works for users with zero unread messages.
</success_criteria>

View file

@ -0,0 +1,197 @@
---
phase: 12-native-tablo-chat
plan: 03
type: execute
wave: 3
depends_on:
- 12-01-PLAN.md
- 12-02-PLAN.md
files_modified:
- backend/internal/web/discussion_broker.go
- backend/internal/web/handlers_discussion.go
- backend/internal/web/handlers_discussion_test.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
- backend/templates/layout.templ
- backend/templates/discussion.templ
- backend/static/discussion-sse.js
autonomous: false
requirements: [CHAT-04, CHAT-05]
must_haves:
truths:
- "D-17/D-21: realtime target is SSE receive plus CSRF-protected HTMX POST send; no WebSocket."
- "D-18: reconnect is silent; do not add visible connection status UI."
- "D-19: stream newly created messages and enough unread badge updates for dashboard/tab cards."
- "D-20: POST renders sender message immediately; SSE updates other open views without duplicates."
- "CHAT-05: no managed chat provider, external realtime runtime, or CDN script is added."
artifacts:
- path: "backend/internal/web/discussion_broker.go"
provides: "owned in-process SSE pub/sub"
- path: "backend/internal/web/handlers_discussion.go"
provides: "SSE stream endpoint and broadcast after insert"
- path: "backend/static/discussion-sse.js"
provides: "minimal local EventSource DOM integration"
---
<objective>
Vertical slice 3: add authenticated SSE realtime delivery and final browser/proxy verification for native discussion.
</objective>
<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-UI-SPEC.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-VALIDATION.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-01-PLAN.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/12-native-tablo-chat/12-02-PLAN.md
</context>
<threat_model>
T-12-10 Unauthorized stream: SSE endpoint must be protected and owner-only like history/send.
T-12-11 Stream resource leak: broker must unregister clients on request context cancellation and avoid blocking broadcast forever on slow clients.
T-12-12 Proxy buffering: stream must flush headers/events/keepalives and be manually verified behind the configured proxy path.
T-12-13 Duplicate messages: sender must not see both POST-rendered and SSE-rendered copies of the same message.
T-12-14 Provider creep: do not add managed realtime provider, WebSocket server, external worker runtime, or runtime CDN script.
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: Add RED SSE handler and broker tests</name>
<files>
- backend/internal/web/handlers_discussion_test.go
</files>
<read_first>
- backend/internal/web/handlers_discussion.go
- backend/internal/web/router.go
- .planning/phases/12-native-tablo-chat/12-VALIDATION.md
</read_first>
<action>
Add tests that initially fail:
1. `TestDiscussionStreamRequiresAuth` expects unauthenticated stream requests to redirect like other protected routes.
2. `TestDiscussionStreamOwnershipReturns404` expects a non-owner stream request to return 404.
3. `TestDiscussionStreamHeaders` expects `Content-Type: text/event-stream`, `Cache-Control: no-cache`, and a flushed/stream-compatible response for an owner request.
4. `TestDiscussionPostBroadcastsToBroker` uses an injectable broker/fake to prove a successful POST publishes the inserted message ID/tablo ID and unread update intent.
5. `TestDiscussionBrokerUnregistersOnCancel` exercises the broker directly to prove clients are removed when context cancels.
</action>
<verify>
<automated>cd backend && go test ./internal/web -run 'TestDiscussionStream|TestDiscussionBroker|TestDiscussionPostBroadcasts' -count=1</automated>
</verify>
<acceptance_criteria>
- Tests cover auth, ownership, stream headers, publish-after-insert, and cancellation cleanup.
- Tests do not pretend to prove browser/proxy streaming delivery; that remains manual UAT.
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Implement SSE broker, stream route, and local browser glue</name>
<files>
- backend/internal/web/discussion_broker.go
- backend/internal/web/handlers_discussion.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
- backend/templates/layout.templ
- backend/templates/discussion.templ
- backend/static/discussion-sse.js
</files>
<read_first>
- backend/embed.go
- backend/templates/layout.templ
- backend/templates/discussion.templ
- backend/internal/web/handlers_discussion.go
- .planning/phases/12-native-tablo-chat/12-RESEARCH.md
</read_first>
<action>
Add a small `DiscussionBroker` with subscribe/unsubscribe and non-blocking or bounded publish behavior. Wire it through `DiscussionDeps` in `cmd/web/main.go` and test routers.
Add protected `GET /tablos/{id}/discussion/stream`. The handler must call `loadOwnedTablo`, require `http.Flusher`, set SSE headers, send periodic keepalive comments, flush after writes, and stop on request context cancellation.
After successful message insert, render/broadcast enough payload for other views to append the message and refresh unread badge targets. Keep DB history as the source of truth for reconnect/missed events.
Add a tiny local `/static/discussion-sse.js` script that creates `EventSource` for discussion containers, appends incoming message fragments, applies OOB badge fragments if used, and suppresses duplicates by message ID. Do not show connection status or typing/presence UI.
Include the local script from `Layout` or conditionally from the discussion template without adding a CDN or framework.
</action>
<verify>
<automated>cd backend && just generate && go test ./internal/web -run 'TestDiscussionStream|TestDiscussionBroker|TestDiscussionPostBroadcasts|TestDiscussion' -count=1</automated>
</verify>
<acceptance_criteria>
- Owner stream requests return SSE-compatible headers and flush data/keepalives.
- Non-owner and unauthenticated stream requests cannot subscribe.
- Successful POST publishes a realtime update after DB insert.
- Local JS is framework-free, silent on reconnect, and avoids sender duplicates.
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Run full backend verification and fix regressions</name>
<files>
- backend/internal/web/handlers_discussion.go
- backend/internal/web/discussion_broker.go
- backend/internal/web/handlers_discussion_test.go
- backend/templates/discussion.templ
- backend/templates/tablos.templ
- backend/static/discussion-sse.js
</files>
<read_first>
- .planning/phases/12-native-tablo-chat/12-VALIDATION.md
- .planning/phases/12-native-tablo-chat/12-UI-SPEC.md
</read_first>
<action>
Run generated-code verification and the full backend suite. Fix compile breaks, route signature updates, templ generation issues, streaming handler regressions, or existing auth/tablo/task/file/event/planning regressions caused by Phase 12 changes.
</action>
<verify>
<automated>cd backend && just generate && TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1 && git diff --check</automated>
</verify>
<acceptance_criteria>
- `cd backend && just generate` exits 0.
- Full backend tests pass with `TEST_DATABASE_URL`.
- `git diff --check` exits 0.
</acceptance_criteria>
</task>
<task type="checkpoint:human-verify">
<name>Task 4: Browser and proxy UAT for realtime discussion</name>
<files></files>
<read_first>
- .planning/phases/12-native-tablo-chat/12-UI-SPEC.md
- .planning/phases/12-native-tablo-chat/12-VALIDATION.md
</read_first>
<action>
Start the app through the normal local dev/proxy path and verify:
1. Direct `/tablos/{id}/discussion` renders the full detail page with Discussion active.
2. Existing messages load oldest-first with day separators, author email, absolute timestamps, and escaped text.
3. Empty discussion shows `No messages yet` and `Start the discussion for this tablo.` while composer remains visible.
4. Posting a valid message renders it immediately for the sender.
5. Empty and too-long messages show the specified validation copy.
6. Dashboard unread badge appears for unread discussion messages and clears after opening the discussion.
7. A second open browser/tab receives a new message without manual refresh.
8. Sender view does not duplicate the POST-rendered message.
9. Reconnect remains silent; no status/presence/typing UI appears.
10. Streaming works through the local/prod reverse proxy path with keepalives or buffering disabled enough for prompt delivery.
</action>
<verify>
<automated>cd backend && TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1</automated>
<manual>Browser confirms CHAT-01..06, D-01..D-26, UI-SPEC, and proxy streaming behavior.</manual>
</verify>
<acceptance_criteria>
- Browser UAT confirms two-view realtime delivery without external provider.
- Reverse proxy streaming behavior is explicitly checked.
- User approves final realtime transport choice as SSE receive plus HTMX POST send.
</acceptance_criteria>
</task>
</tasks>
<verification>
Run:
- `cd backend && just generate`
- `cd backend && go test ./internal/web -run 'TestDiscussionStream|TestDiscussionBroker|TestDiscussionPostBroadcasts|TestDiscussion' -count=1`
- `cd backend && TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1`
- `git diff --check`
- Browser/proxy UAT from Task 4
</verification>
<success_criteria>
- CHAT-04: open discussion views receive messages without manual refresh.
- CHAT-05: realtime uses only Xtablo-owned Go/Postgres/static infrastructure.
- D-17..D-21 are implemented with SSE receive, HTMX POST send, silent reconnect, message/badge updates, and no WebSocket.
- Phase 12 is ready for final verification after browser/proxy UAT.
</success_criteria>

View file

@ -0,0 +1,53 @@
# Phase 12: Native Tablo Chat - Patterns
**Mapped:** 2026-05-16
## Closest Existing Analogs
| New Concern | Closest Existing File | Reuse Pattern |
|-------------|-----------------------|---------------|
| Discussion routes and deps | `backend/internal/web/router.go` | Add explicit deps to `NewRouter`; mount protected static segments before parametric children. |
| Owned tablo child resource | `backend/internal/web/handlers_events.go` | Call `loadOwnedTablo`, return 404 for invalid/non-owned, render HTMX fragment or full detail page. |
| DB-backed handler tests | `backend/internal/web/handlers_events_test.go` | Use `setupTestDB`, `sessionCookieForUser`, `getCSRFToken`, full router requests, and response/DB assertions. |
| Tab content integration | `backend/templates/tablos.templ` | Add fifth tab link and active-tab branch inside `TabloDetailPage`. |
| Child tab template | `backend/templates/events.templ` | Keep a single tab fragment with form/list subcomponents and restrained slate/blue styling. |
| Form errors | `backend/templates/auth_form_errors.templ` | Reuse `FieldError` and `GeneralError` for textarea validation. |
| Nullable text params | `backend/internal/web/handlers_events.go` | Use `pgtype.Text{String: s, Valid: s != ""}`. |
| Local static assets | `backend/templates/layout.templ`, `backend/embed.go` | Serve script from `/static/...`; embedding includes all static files. |
| Dashboard card extension | `backend/templates/tablos.templ` | Keep badge small inside the existing card header; no layout rewrite. |
## File Mapping
| File | Role | Notes |
|------|------|-------|
| `backend/migrations/0009_discussion.sql` | Schema | Add `discussion_messages` and `discussion_read_states`; include constraints/indexes. |
| `backend/internal/db/queries/discussion.sql` | sqlc queries | History, insert, read-state upsert, unread counts, message lookup after insert. |
| `backend/internal/web/handlers_discussion.go` | HTTP handlers | Discussion tab, send, stream, read-state helpers. |
| `backend/internal/web/discussion_broker.go` | SSE broker | Small per-process pub/sub with context cleanup and keepalive support. |
| `backend/internal/web/handlers_discussion_test.go` | Tests | DB-backed contracts for all discussion behavior. |
| `backend/templates/discussion.templ` | UI | Message list, rows, day separators, composer, empty state, SSE container targets. |
| `backend/templates/discussion_forms.go` | View models | Form/errors, message row formatting, date grouping, unread badge helpers. |
| `backend/templates/tablos.templ` | Integration | Add Discussion tab and dashboard unread badge support. |
| `backend/static/discussion-sse.js` | Browser EventSource glue | Local, minimal, no framework; append fragments and apply OOB badge updates. |
## Required Local Conventions
- Keep sqlc-generated Go files uncommitted; execution runs `cd backend && just generate`.
- Keep all discussion routes inside `auth.RequireAuth`.
- Use `loadOwnedTablo` before any discussion query or stream registration.
- Keep non-owner behavior as 404, not 403.
- Use templ escaped expressions for message body and metadata.
- Keep all added JS local and small; no CDN, no client framework.
- Preserve non-HTMX fallback for history page and send redirect/refresh behavior.
## Decision Coverage Map
| Decisions | Implementation Surface |
|-----------|------------------------|
| D-01/D-02/D-03/D-04 | `TabloDetailPage` fifth tab, `/tablos/{id}/discussion` handler. |
| D-05/D-26 | `discussion_read_states`, dashboard unread queries, badge template. |
| D-06/D-07/D-08/D-09/D-10 | `discussion.templ` message list, day grouping, composer, empty state. |
| D-11/D-12/D-13/D-14 | Schema nullable edit/delete metadata; no UI controls. |
| D-15/D-16 | Composer textarea/button only. |
| D-17/D-18/D-19/D-20/D-21 | HTMX POST send, SSE stream, local script, no WebSockets. |
| D-22/D-23/D-24/D-25 | Owner-only handlers, email fallback author label, `1 participant`. |

View file

@ -0,0 +1,165 @@
# Phase 12: Native Tablo Chat - Research
**Researched:** 2026-05-16
**Domain:** Go + HTMX persisted discussion, Postgres, SSE
**Confidence:** HIGH
<user_constraints>
## User Constraints
Locked decisions from `12-CONTEXT.md` and `12-UI-SPEC.md`:
- D-01/D-02/D-03/D-04: Discussion is the fifth tablo detail tab, label `Discussion`, after Events, with direct full-page fallback at `/tablos/{id}/discussion`.
- D-05/D-26: Dashboard tablo cards need persistent per-user/tablo unread badges.
- D-06/D-07/D-08/D-09/D-10: Message rows show author, absolute timestamp, text, oldest first, composer at bottom, day separators, and the specified empty state copy.
- D-11/D-12/D-13/D-14: No edit/delete UI in Phase 12, but nullable edit/delete metadata must exist in the message schema without forcing future delete semantics.
- D-15/D-16: Composer is a plain textarea plus send button; no Enter-to-send behavior.
- D-17/D-18/D-19/D-20/D-21: Transport is SSE receive plus CSRF-protected HTMX POST send; reconnect is silent; POST renders the sender message immediately; SSE updates other open views; no WebSocket unless a hard blocker appears.
- D-22/D-23/D-24/D-25: Owner-only access under the current tablo model; author label uses display name if available and email fallback; current schema has email only; header can show `1 participant`.
- UI-SPEC: Use existing Layout, tab shell, local templ/ui patterns, restrained white/slate/blue styling, no JS framework, no rich chat controls, no presence/typing/status UI.
Deferred/out of scope:
- Typing, presence, read receipts, reactions, replies, rich media, attachments, search, pinned messages, edit UI, delete UI, membership/invitations, and exact future delete behavior.
</user_constraints>
<architectural_responsibility_map>
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| Persisted message history | Database | Backend | Postgres owns message durability; handlers only authorize and render. |
| Discussion tab rendering | Frontend Server | Browser | templ renders the full page and HTMX fragments; browser only swaps/appends HTML. |
| Message send | Backend | Database | A normal POST validates CSRF/body, inserts, updates read state, renders the sender fragment, and broadcasts. |
| Realtime receive | Backend | Browser | Go owns authenticated SSE streams; browser EventSource receives fragments and applies DOM updates with a tiny local script. |
| Unread counts | Database | Backend | Persistent per-user/tablo read state is needed across reloads and future sharing. |
| Authorization | Backend | Database | `loadOwnedTablo` and ownership-filtered queries preserve current 404-for-non-owner behavior. |
</architectural_responsibility_map>
<research_summary>
## Summary
The existing Go backend already has the right architecture for Phase 12: protected chi routes, `loadOwnedTablo` for owner-only access, sqlc/goose for schema and query changes, templ fragments for full-page fallback, HTMX for normal form submission, and local embedded static assets. Phase 12 should add a native `discussion_messages` table, a persistent `discussion_read_states` table, a `DiscussionDeps` dependency, handlers/templates for the fifth detail tab, and a small Go SSE broker for live append/unread updates.
The safest first version is not the historical Cloudflare worker/WebSocket chat architecture. Browser-to-server sends should remain CSRF-protected POST requests. Receive-only realtime should be SSE from protected routes, with a per-process broker and database-backed history/read state as source of truth. This satisfies the phase requirement without managed providers and keeps degraded non-JS sends/history usable.
**Primary recommendation:** Build the feature in three slices: persisted Discussion tab and POST send, unread badge/read-state integration, then SSE realtime with browser/proxy UAT.
</research_summary>
<standard_stack>
## Standard Stack
| Component | Existing Version/Pattern | Purpose | Recommendation |
|-----------|--------------------------|---------|----------------|
| Go HTTP | stdlib + chi v5.2.5 | Routes, streaming responses | Use `http.Flusher` and request context cancellation for SSE. |
| templ | v0.3.1020 | Server-rendered HTML | Add discussion templates; rely on templ escaping for message text. |
| HTMX | v2 local static asset | POST send and fragment swaps | Keep composer as HTMX POST; do not add htmx SSE extension unless vendored locally. |
| Browser EventSource | Native browser API | Receive SSE | Add one small local script under `backend/static/` for EventSource append/OOB behavior. |
| Postgres/sqlc/goose | Existing backend pattern | Durable schema and typed queries | Add `0009_discussion.sql` and `discussion.sql`; generated Go remains gitignored. |
No new managed service, frontend framework, CDN runtime, or WebSocket infrastructure is needed.
</standard_stack>
<architecture_patterns>
## Architecture Patterns
### System Architecture
```
GET /tablos/{id}/discussion
-> RequireAuth -> loadOwnedTablo -> ListDiscussionMessages + MarkDiscussionRead
-> templ full page or tab fragment
POST /tablos/{id}/discussion/messages
-> RequireAuth + CSRF -> loadOwnedTablo -> validate body
-> InsertDiscussionMessage -> MarkDiscussionRead
-> render sender fragment immediately
-> broadcast message + unread badge refresh to SSE broker
GET /tablos/{id}/discussion/stream
-> RequireAuth -> loadOwnedTablo -> register SSE client
-> keepalive comments + message/unread events until context cancels
GET /
-> RequireAuth -> ListTablosWithDiscussionUnreadCounts
-> dashboard cards render numeric badge when count > 0
```
### Recommended Project Structure
```
backend/
├── migrations/0009_discussion.sql
├── internal/db/queries/discussion.sql
├── internal/web/handlers_discussion.go
├── internal/web/handlers_discussion_test.go
├── internal/web/discussion_broker.go
├── templates/discussion.templ
├── templates/discussion_forms.go
└── static/discussion-sse.js
```
### Pattern 1: Ownership Gate First
Use `loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})` before listing, inserting, marking read, or opening SSE. Preserve the established behavior where invalid UUIDs, missing tablos, and non-owner access return 404.
### Pattern 2: POST Response Is Sender Source Of Truth
The POST handler should render the sender's new message immediately, ideally as an appendable message fragment plus any OOB updates needed for composer/errors. SSE should broadcast to other open views; the browser-side receiver must suppress duplicates for the sender if it is also subscribed.
### Pattern 3: Database-Backed Read Cursor
Use a read-state row keyed by `(tablo_id, user_id)` with `last_read_message_id` and `last_read_at`. Unread count can be computed by joining messages for the owned tablo and counting rows newer than the read cursor/after `last_read_at`. This is durable across reloads and future-compatible with sharing.
### Pattern 4: Per-Process SSE Broker, DB Is Source Of Truth
A small in-memory broker is acceptable for the current single-process Go app because persisted history covers missed events on reconnect. The broker should never be the only source of message history or unread state.
</architecture_patterns>
<common_pitfalls>
## Common Pitfalls
### Pitfall 1: Forgetting Full-Page Fallback
**What goes wrong:** `/tablos/{id}/discussion` only works as an HTMX fragment and direct loads lose the detail shell.
**How to avoid:** Match `TabloEventsTabHandler`: return `DiscussionTabFragment` for `HX-Request`, otherwise render `TabloDetailPage(..., activeTab="discussion")`.
### Pitfall 2: Relying On SSE For The Sender's Own Message
**What goes wrong:** The sender sees delay, duplicate messages, or no update if the stream is not connected.
**How to avoid:** POST inserts and returns the sender message fragment immediately; SSE is for other open views and badge refreshes.
### Pitfall 3: In-Memory Read State
**What goes wrong:** Dashboard badges reset or become stale after reload/restart.
**How to avoid:** Store read state in Postgres and compute badge counts from messages/read-state.
### Pitfall 4: Buffered SSE In Reverse Proxies
**What goes wrong:** Events do not arrive until buffers fill or the connection closes.
**How to avoid:** Set `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, flush after each event/keepalive, and manually verify the local/prod reverse proxy path.
### Pitfall 5: CSRF Expectations On EventSource
**What goes wrong:** Trying to send custom CSRF headers with native EventSource creates unnecessary complexity.
**How to avoid:** Keep SSE as authenticated GET with session cookie and no mutation. Keep mutations as CSRF-protected POST.
### Pitfall 6: Unescaped Message Rendering
**What goes wrong:** Chat messages become an XSS surface.
**How to avoid:** Render body through templ escaped expressions, preserve line breaks with CSS (`whitespace-pre-wrap`) rather than raw HTML, and test with HTML-looking input.
### Pitfall 7: Adding Rich Chat UI Too Early
**What goes wrong:** Phase expands into presence, typing, replies, delete/edit UX, attachments, and client state.
**How to avoid:** Enforce UI-SPEC: rows, day separators, composer, unread badge only.
</common_pitfalls>
## Validation Architecture
Phase 12 needs DB-backed integration tests plus one manual/browser streaming checkpoint.
- Unit/DB integration tests should cover migration/query behavior, owner-only history, POST validation, CSRF, max body length, escaped rendering, full-page fallback, and unread count/read-state behavior.
- Handler tests can verify SSE response headers and that unauthorized/non-owner streams are blocked. Full end-to-end delivery with two browsers and reverse proxy buffering requires manual UAT because `httptest.ResponseRecorder` is not a faithful streaming/proxy environment.
- Final verification should run `cd backend && just generate`, targeted `go test ./internal/web -run 'TestDiscussion|TestTablosListDiscussionUnread' -count=1`, full backend tests with `TEST_DATABASE_URL`, and `git diff --check`.
## Open Questions
Resolved during research:
- **Realtime transport:** Use SSE receive + HTMX POST send. No hard blocker found.
- **Author display name:** Do not add user display-name schema in Phase 12; email fallback satisfies D-23/D-24 and avoids unrelated account profile scope.
- **Unread model:** Use persistent `discussion_read_states`, even owner-only, to satisfy D-05/D-26 and future sharing.

View file

@ -0,0 +1,67 @@
---
phase: 12
slug: native-tablo-chat
status: draft
nyquist_compliant: true
wave_0_complete: true
created: 2026-05-16
---
# Phase 12 - Validation Strategy
> Per-phase validation contract for native tablo discussion.
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go test with existing DB-backed integration harness |
| **Config file** | `backend/sqlc.yaml`, `backend/justfile` |
| **Quick run command** | `cd backend && go test ./internal/web -run 'TestDiscussion|TestTablosListDiscussionUnread' -count=1` |
| **Full suite command** | `cd backend && TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1` |
| **Estimated runtime** | ~60 seconds for full backend suite with local Postgres |
## Sampling Rate
- **After every task commit:** Run the targeted discussion test command for touched behavior.
- **After every plan wave:** Run `cd backend && just generate` plus the full backend suite.
- **Before `$gsd-verify-work`:** Full suite and browser/SSE UAT must be green.
- **Max feedback latency:** 60 seconds for automated checks.
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 12-01-01 | 01 | 1 | CHAT-03 | T-12-01/T-12-02 | Message schema carries tablo/author/body/timestamps/edit/delete metadata | integration | `cd backend && just generate` | Yes | pending |
| 12-01-02 | 01 | 1 | CHAT-01/CHAT-02/CHAT-06 | T-12-03/T-12-04/T-12-05 | Owner-only history and POST validation with escaped output | integration | `cd backend && go test ./internal/web -run 'TestDiscussion' -count=1` | Yes | pending |
| 12-01-03 | 01 | 1 | CHAT-01/CHAT-06 | T-12-05 | UI contract renders escaped history, day separators, composer, and fallback | integration | `cd backend && go test ./internal/web -run 'TestDiscussion' -count=1` | Yes | pending |
| 12-02-01 | 02 | 2 | CHAT-01 | T-12-06 | Unread count comes from persistent owned read state | integration | `cd backend && go test ./internal/web -run 'TestTablosListDiscussionUnread|TestDiscussionReadState' -count=1` | Yes | pending |
| 12-02-02 | 02 | 2 | CHAT-01 | T-12-06/T-12-07 | Dashboard badge shows only accurate unread counts | integration | `cd backend && go test ./internal/web -run 'TestTablosListDiscussionUnread' -count=1` | Yes | pending |
| 12-03-01 | 03 | 3 | CHAT-04/CHAT-05 | T-12-08/T-12-09 | Authenticated SSE streams flush owned message events without external provider | integration/manual | `cd backend && go test ./internal/web -run 'TestDiscussionStream' -count=1` | Yes | pending |
| 12-03-02 | 03 | 3 | CHAT-04/CHAT-05 | T-12-08/T-12-10 | Two browser views receive messages without refresh through local/prod proxy path | manual | Browser UAT | N/A | pending |
## Wave 0 Requirements
Existing infrastructure covers all phase requirements:
- `backend/internal/web/testdb_test.go` provides DB-backed schema-isolated tests.
- `backend/internal/web/handlers_events_test.go` provides full-router authenticated child-resource patterns.
- `backend/justfile` provides `just generate`, `just test`, and local dev server workflows.
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Two open browser views receive new discussion messages without manual refresh | CHAT-04 | Requires real browser EventSource behavior and DOM updates | Open the same tablo discussion in two tabs/sessions, send a message in one, confirm the other appends it without refresh and sender does not duplicate it. |
| Streaming through reverse proxy path is not buffered | CHAT-04/CHAT-05 | `httptest` cannot prove proxy buffering/keepalive behavior | Run the app behind the local/prod reverse proxy path, keep the stream open, send a message, confirm delivery and keep-alive behavior. |
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or manual-only justification.
- [x] Sampling continuity: no 3 consecutive tasks without automated verify.
- [x] Wave 0 covers all missing test infrastructure references.
- [x] No watch-mode flags.
- [x] Feedback latency target < 60s.
- [x] `nyquist_compliant: true` set in frontmatter.
**Approval:** pending execution