docs: split CLAUDE.md into docs/{development,deployment,history/fase-*}

CLAUDE.md shrinks to rules + status + index (175 lines, down from 476).
Dev setup, prod deploy, and per-phase build records move to docs/.
Gotchas regrouped by domain (auth, frontend, PWA, testing, backend, deploy,
platform) and unnumbered so they don't drift as new ones land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 22:21:20 +02:00
parent 3d85d0a50e
commit 768b3cda58
11 changed files with 441 additions and 403 deletions

48
docs/history/fase-1.md Normal file
View File

@@ -0,0 +1,48 @@
# Fase 1 — what has been built
- `supabase/migrations/001_users.sql``users` table, `handle_new_user` trigger
- `supabase/migrations/002_collectives.sql``collectives`, `collective_members`, `collective_invitations`; auto-promote-oldest-admin trigger
- `supabase/migrations/003_rls.sql` — full RLS policies, `is_active_member`/`is_member`/`is_admin` helpers, `accept_invitation()` function
- `supabase/migrations/004_storage.sql``avatars` bucket (private, signed URLs)
- `apps/web/src/lib/supabase.ts` — Supabase singleton with PKCE flow
- `apps/web/src/lib/auth.ts``login()` / `logout()` via `supabase.auth.signInWithOAuth`
- `apps/web/src/lib/stores/auth.ts``currentUser`, `authLoading`, `isAuthenticated`, `displayName`
- `apps/web/src/lib/stores/collective.ts``currentCollective`, `userCollectives`, `collectiveMembers`
- `apps/web/src/lib/components/Avatar.svelte` — initials / emoji / upload with deterministic colour hash
- `apps/web/src/lib/components/ImageCropper.svelte` — lazy-loaded cropperjs, 1:1, 256×256 WebP output
- `apps/web/src/routes/+layout.svelte` — auth state listener, collective bootstrap
- `apps/web/src/routes/(app)/+layout.ts` — SSR disabled (`ssr: false`); no auth check here (see gotcha below)
- `apps/web/src/routes/(app)/+layout.svelte` — sidebar with collective switcher; auth redirect via `$effect`
- `apps/web/src/routes/auth/callback/+page.svelte` — PKCE code exchange
- `apps/web/src/routes/onboarding/+page.svelte` — create collective or join via link
- `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator
- `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware)
- `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out
- `apps/web/messages/en.json` + `es.json` — all Fase 1 + 2a strings
- `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired)
- `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide)
- `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively)
## Test suite — initial scaffolding
- `packages/test-utils/` — shared test infrastructure (Vitest integration tests)
- `package.json``@colectivo/test-utils`; depends on `@supabase/supabase-js`, `jose`, `pg`
- `vitest.config.ts` — loads root `.env` via `loadEnv`; single-fork mode (no parallelism races)
- `src/seed-constants.ts` — fixed UUIDs for all 5 seed users + collective + seed list
- `src/supabase-clients.ts``createClientAs(userId)` signs HS256 JWT with `SUPABASE_JWT_SECRET`; `createAdminClient()` uses service role key
- `src/db-helpers.ts``sql()` via raw `pg` Pool for direct DB manipulation (bypasses RLS); used to set `deleted_at = 8 days ago` etc.
- `tests/rls-isolation.test.ts` — F-series: Eva (non-member) sees zero rows, all writes blocked
- `tests/rls-collective.test.ts` — B-series: collective read/update/invite by role
- `tests/rls-lists.test.ts` — C-series: list CRUD, soft-delete, trash window, cross-collective isolation
- `tests/rls-items.test.ts` — D-series: item CRUD by role, cross-collective item isolation
- `tests/rls-frequency.test.ts` — E-series: frequency read-only + trigger populates on INSERT
- `supabase/tests/*.sql` each start with `CREATE EXTENSION IF NOT EXISTS pgtap;` so they're idempotent on a fresh DB
- `001_accept_invitation.sql``accept_invitation()` happy path + not_found / expired / already_used; uses `set_config('request.jwt.claim.sub', …)` to set `auth.uid()` for the test session, and looks up by `token` (not `id`)
- `002_item_frequency_trigger.sql` — trigger normalizes name with `lower(trim(...))` and increments `use_count` on repeat INSERTs. RLS write-blocks are covered by the Vitest suite instead (postgres superuser bypasses RLS here)
- `003_promote_on_admin_leave.sql` — deletes a synthetic admin from `public.users` to fire the `BEFORE DELETE` trigger and asserts the oldest remaining member is promoted
- `apps/web/playwright.config.ts` — Playwright config; `globalSetup` is a Keycloak health check (not a session cache), `reuseExistingServer: true`
- `apps/web/tests/fixtures/users.ts` — seed user constants (Ana, Borja, Carmen, David, Eva)
- `apps/web/tests/fixtures/login.ts``loginAs(page, user)` runs the real OAuth flow (Keycloak → GoTrue PKCE) and waits for the session token to land in localStorage. Each test that needs auth calls this in `beforeEach`; cached `storageState` is NOT used (see the Playwright storageState gotcha in `CLAUDE.md`)
- `apps/web/tests/e2e/auth.test.ts` — A-series: unauthenticated redirect, full login, collective visible in sidebar, sign-out, guest login
- `apps/web/tests/e2e/lists.test.ts` — C-series: create (with reload-persistence check), soft-delete → appears in trash drawer, archive, guest read-only
- `apps/web/tests/e2e/items.test.ts` — D-series: seed list renders, add item (with reload-persistence check), toggle check, desktop-hover delete, frequency suggestions, guest read-only

8
docs/history/fase-2a.md Normal file
View File

@@ -0,0 +1,8 @@
# Fase 2a — what has been built
- `supabase/migrations/005_shopping.sql``shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
- `supabase/migrations/006_item_frequency.sql``item_frequency` table, `fn_record_item_frequency` trigger (SECURITY DEFINER, fires on every `shopping_items` INSERT)
- `apps/web/src/lib/stores/lists.ts` — shopping lists store (optimistic CRUD) + item operations + suggestion fetching
- `apps/web/src/lib/components/ItemSuggestions.svelte` — frequency chips with active-prefix highlighting
- `apps/web/src/routes/(app)/lists/+page.svelte` — overview: featured card + 2-col grid + completed section + trash view (sticky input replaced by masthead "New list" button in Fase 5.8)
- `apps/web/src/routes/(app)/lists/[id]/+page.svelte` — list detail: items with checkbox, qty stepper (/N/+), inline edit, swipe-to-reveal-delete (touch) + hover delete (desktop), drag & drop reorder via `svelte-dnd-action`, sticky add form with `ItemSuggestions`

14
docs/history/fase-2b.md Normal file
View File

@@ -0,0 +1,14 @@
# Fase 2b — what has been built
- `supabase/migrations/007_realtime_publication.sql` — adds shopping_items + shopping_lists to `supabase_realtime` publication with `REPLICA IDENTITY FULL`
- `apps/web/src/lib/stores/realtimeSync.ts``subscribeToList(listId, onEvent)` postgres_changes subscription helper; `applyItemEvent` reducer for INSERT/UPDATE/DELETE
- `/lists/[id]/+page.svelte` — wired to realtimeSync with pendingTempIds echo-deduplication. Mutations use client-generated UUIDs so offline retries are idempotent (PK-conflict = success)
- `apps/web/src/lib/sync/queue.ts``SyncQueue` class (IDB-backed pending_ops, FIFO flush, MAX_ATTEMPTS retry, PK-conflict-as-success)
- `apps/web/src/lib/sync/index.ts` — app-wide SyncQueue singleton; `enqueueOp`, `hydrateSyncState`, auto-flush on `window.online`
- `apps/web/src/lib/stores/syncStatus.ts` — derived store: `offline | syncing | synced` from `navigator.onLine` + `pendingOpsCount`
- `apps/web/src/lib/components/SyncBanner.svelte` — renders the offline/syncing indicator; mounted in list detail + session views
- `apps/web/src/routes/(app)/lists/[id]/session/+page.svelte` — Modo Compra full-screen view (fixed positioning overlays the sidebar), 56px toggles, flip animation, confirm modal, completeList → redirect
- `apps/web/messages/*.json``sync_offline`, `sync_syncing`, `list_start_session`
- `packages/test-utils/src/realtime-helpers.ts``subscribePostgresChanges(client, opts)` with `waitFor(predicate, ms)` — awaits SUBSCRIBED handshake, dedups events, leaks nothing between tests
- Vitest unit harness in `apps/web/``vitest.config.ts` (jsdom env) + `vitest.setup.ts` (fake-indexeddb/auto) + `pnpm --filter @colectivo/web test:unit`
- Realtime config gotchas documented in `project_realtime_config` memory: tenant name, DB_USER=supabase_admin, `SEED_SELF_HOST: "true"`, pre-created `realtime` schema, publication + REPLICA IDENTITY FULL

12
docs/history/fase-3.md Normal file
View File

@@ -0,0 +1,12 @@
# Fase 3 — what has been built
- `supabase/migrations/008_tasks.sql``task_lists`, `tasks` with completion-consistency CHECK (`is_completed ⇔ completed_by ⇔ completed_at`), helper `task_list_collective_id()`, RLS by role
- `supabase/migrations/009_notes.sql``notes` with `note_color` enum (8 values), `is_pinned`, `is_archived` (mutually exclusive via CHECK), `deleted_at` (7d trash window in RLS), `fn_notes_touch` trigger refreshing `updated_at`/`updated_by`, `pg_cron` job `purge-deleted-notes` at 03:10
- `apps/web/src/lib/stores/tasks.ts` — task lists CRUD (optimistic) + task ops (`addTask`, `completeTask`, `renameTask`, `deleteTask`, `reorderTasks`)
- `apps/web/src/lib/stores/notes.ts``loadNotes/loadArchivedNotes/loadTrashedNotes`, `createNote`, `patchNote` (always sets `updated_by`), `pinNote/unpinNote`, `archiveNote/unarchiveNote`, `trashNote/restoreNote`, `permanentDeleteNote`, `duplicateNote`, `NOTE_COLORS`
- `/tasks` (overview) + `/tasks/[id]` (detail with `dndzone` reorder + `flip` animation; lazy-loads `collective_members` for "Completed by …" attribution; 5s polling per analysis §6)
- `/notes` (board with pinned section testid `notes-pinned`) + `/notes/[id]` (editor: title input + textarea, 500ms debounced autosave, color picker, pin/archive/duplicate/trash) + `/notes/archive` + `/notes/trash` (30s polling on board)
- `apps/web/messages/{en,es}.json` — full Tareas + Notas string set (`tasks_*`, `notes_*`)
- pgTAP `005_tasks_rls.sql` (5 tests on the completion CHECK), `006_notes_trash_purge.sql` (4 tests: inline-runs the purge SQL + verifies pin/archive CHECK)
- Vitest `rls-tasks.test.ts` (14 tests) + `rls-notes.test.ts` (15 tests)
- Playwright `tasks.test.ts` (3 tests) + `notes.test.ts` (4 tests)

9
docs/history/fase-4.md Normal file
View File

@@ -0,0 +1,9 @@
# Fase 4 — what has been built
- `supabase/migrations/010_search_vectors.sql` — STORED GENERATED `search tsvector` columns on `shopping_items`/`tasks`/`notes` + GIN indexes + `search_result_type` enum + `search_in_collective(p_collective_id, p_query, p_types, p_creator, p_from, p_to)` SECURITY INVOKER function (RLS-aware, trash-excluded) + GRANT EXECUTE to anon/authenticated. Config is `simple` (no stemmer — bilingual en/es).
- `apps/web/src/lib/stores/search.ts` — RPC wrapper with `SearchRow` + `SearchFilters` types
- `/search` route — debounced input (300ms, ≥2 chars), filter chips (Lists/Tasks/Notes; all-active = no filter sent), results grouped by type with `data-testid="search-group-<type>"`, deep-link to the right route per result type
- `apps/web/messages/{en,es}.json` — search strings (`search_filter_*`, `search_group_*`, `search_empty`, `search_hint`)
- pgTAP `007_search_tsvector.sql` (9 tests: columns, GIN indexes, function signature, generated-vector invariant)
- Vitest `search.test.ts` (10 tests across S-01..S-04) + `rls-audit.test.ts` (42 tests: 3 intruders × 14 cross-collective ops — proves zero leakage across every domain table)
- Playwright `search.test.ts` (2 tests: SR-01 happy-path, SR-02 filter deactivation)

55
docs/history/fase-5.md Normal file
View File

@@ -0,0 +1,55 @@
# Fase 5 — what has been built (complete in scope)
## 5.8 big-button create (match /notes)
- `/lists` sticky bottom create bar removed; masthead gains a primary "New list" button next to the Trash ghost icon
- `handleCreate` creates with empty name + `goto(/lists/[id])`; detail view shows a prominent editable title input (2632px) with 500 ms autosave (`renameList`)
- Matches `/notes` pattern end-to-end
## 5.10 row redesign (button-row + gesture toggle + overlay + drag handles)
- Shopping items have **no checkbox** in normal mode. Row is `drag-handle | name-button | qty stepper`. Checked items render strikethrough + muted.
- **Swipe right on unchecked → mark checked** (green reveal), **swipe left on checked → uncheck** (neutral reveal). Opposite direction is a no-op snap-back. `SWIPE_COMMIT_THRESHOLD=120`, `SWIPE_REVEAL_WIDTH=96`. No undo toast — the gesture is symmetric and the inverse swipe reverses.
- **Double-tap opens an overlay** (Dialog / bottom sheet) with: name input + Delete (red) + Confirm + X close. 300 ms tap-window. Delete still routes through `scheduleUndoable``UndoToast`.
- **Drag handle** on the left, always visible on mobile, `md:opacity-0 md:group-hover:opacity-100` on desktop. Handle's `onpointerdown` flips a `dragEnabled` flag so dnd only kicks in from the handle, leaving row swipes untouched.
- `unit` column **dropped** (migration `011_drop_shopping_items_unit.sql`). The double-tap overlay only edits name, so an unreachable `unit` would be stranded data. Users encode units inside the name (e.g. "Milk 1L"); seed updated accordingly.
## 5.11 selection mode + bulk actions
- **Entry**: long-press 500 ms on mobile or `data-testid="selection-toggle"` button in the desktop header. Preselects the row you pressed (mobile).
- Inside selection mode: single-tap toggles selection, swipe + double-tap disabled, stepper + drag handle hidden. Row gains a checkbox on the left.
- **Chrome**: header turns into `data-testid="selection-bar"` (dark slate-900 with "N selected" + Cancel + desktop-inline actions). Mobile bottom action bar (`data-testid="selection-action-bar"`) above BottomTabBar with Delete / Move / Mark-checked.
- **Bulk actions** (one Supabase call each): `bulkDeleteItems` + single UndoToast with batch snapshot, `bulkMoveItems` via a list picker sheet (`data-testid="move-picker"`) listing the collective's other active lists + a "Create new list" row, `bulkCheckItems` filters `is_checked=false` so it only marks, never toggles.
## 5.12 detail-view chrome (hide MobileTopBar, sticky contextual bars)
- On `/lists/[id]`, `/tasks/[id]`, `/notes/[id]`, `/lists/[id]/session` the global `MobileTopBar` is hidden via an `isDetailRoute` regex check in `(app)/+layout.svelte`. The route's own contextual header (back + title + actions) becomes the only top chrome and is now `sticky top-0 z-30 bg-surface/80 backdrop-blur-xl`.
- `BottomTabBar` stays visible so users can hop between sections.
## 5.15 detail-view title chrome cleanup
- `/lists/[id]` and `/tasks/[id]` dropped the uppercase eyebrow label ("ACTIVE LIST" / "TASK MANAGER") that used to sit above the editable title. Title moved up into that space.
- `/lists/[id]` status signal replaces the hardcoded "ACTIVE LIST": `completed` → title with `text-text-secondary line-through`; `archived` → muted pill next to title (`data-testid="list-status-pill"`); `active` → plain title.
- `/tasks/[id]` has no status on `task_lists`; just the label drop.
## 5.14 inline add-form (last element of the list)
- `/lists/[id]` + `/tasks/[id]` no longer have a bottom-sticky add form. The form renders **inline as the last child of the unchecked / pending section** (just above Checked / Completed). Empty-state branch shows message + form beneath it.
- On `/lists/[id]` the frequency chips (`ItemSuggestions`) moved below the input (input row on top, chips on second line). Mobile: horizontal scroll; desktop: wrap.
- Hides entirely while `selectionMode` is active.
- Svelte 5 snippets `addItemForm` / `addTaskForm` so the form is rendered in both the empty-state branch and the between-sections slot without duplication.
- `data-testid="add-item-form"` / `"add-task-form"` hooks added for future placement assertions.
## Earlier sub-phases (still relevant)
- `apps/web/src/lib/components/layout/DesktopSidebar.svelte` — desktop sidebar extracted from `(app)/+layout.svelte`, `data-testid="desktop-sidebar"`, `hidden md:flex`
- `apps/web/src/lib/components/layout/MobileTopBar.svelte` — hamburger + collective name + avatar link; `md:hidden sticky top-0 z-30 backdrop-blur-xl`
- `apps/web/src/lib/components/layout/BottomTabBar.svelte` — 4 nav items icon-only, glassmorphism, `pb-[env(safe-area-inset-bottom)]`, `aria-current="page"` on the active route, `data-testid="bottom-tab-bar"`
- `apps/web/src/lib/components/layout/MobileDrawer.svelte` — backdrop + collective switcher + manage/settings/logout, `data-testid="mobile-drawer"`
- `apps/web/src/lib/components/ScreenMasthead.svelte` — uppercase label (`data-testid="masthead-label"`) + big title (`data-testid="masthead-title"`) + optional `meta` / `actions` snippets. Applied on `/lists`, `/tasks`, `/notes`, `/search`.
- `apps/web/src/lib/sync/undoQueue.ts``scheduleUndoable({ label, restore, commit, ttlMs? })` with 4-s TTL; auto-commits on timeout, `undo(id)` cancels + restores
- `apps/web/src/lib/components/UndoToast.svelte` — rendered globally in `(app)/+layout.svelte`, shows latest undoable, offset above bottom tab bar on mobile
- `/lists/[id]` swipe-delete: `SWIPE_MAX=96`, `SWIPE_THRESHOLD=48` (latch-open), `SWIPE_COMMIT_THRESHOLD=200` (full-swipe commit via `scheduleUndoable`)
- `apps/web/src/lib/components/ItemSuggestions.svelte` — mobile: horizontal scroll with `[scrollbar-width:none]`; desktop: wraps
- `(app)/+layout.svelte`**critical fix**: outer container now `flex flex-col md:flex-row` (was `flex` only). Without this MobileTopBar and main rendered as horizontal siblings on mobile and the masthead was pushed off-screen.
- Mobile tests: `mobile-shell.test.ts` (M-01..M-04), `mobile-masthead.test.ts` (M-05 × 4 routes), `mobile-swipe-delete.test.ts` (M-06/M-07 `.skip` — Chromium touch emulation too flaky), `undoQueue.test.ts` (5 unit tests)

35
docs/history/fase-6.md Normal file
View File

@@ -0,0 +1,35 @@
# Fase 6 — what has been built
## 6.1 PWA `injectManifest` migration
- `apps/web/src/sw.ts` — minimal custom SW: `precacheAndRoute(self.__WB_MANIFEST)` + `skipWaiting`/`clients.claim`. Named `sw.ts` (not `service-worker.ts`) to dodge SvelteKit's built-in interception (see the PWA gotchas in `CLAUDE.md`).
- `apps/web/vite.config.ts` — switched `@vite-pwa/sveltekit` to `strategies: 'injectManifest'` with `srcDir: 'src'`, `filename: 'sw.ts'`, `injectManifest.globPatterns` for the built shell.
- `apps/web/src/routes/+layout.svelte` — registers the SW via `onMount() → import('virtual:pwa-register').registerSW({ immediate: true })`. `@vite-pwa/sveltekit` does NOT auto-register; without this call the virtual module is a no-op and `navigator.serviceWorker.getRegistration()` returns `undefined`.
- `apps/web/tests/e2e/pwa.test.ts` — P-01 (manifest shape) + P-02 (SW registration via `waitForFunction` poll).
> Note: the prod build later switched back to `strategies: 'generateSW'` — see the PWA gotcha for the reason.
## 6.2 Manifest + iOS meta tags + placeholder icons
- Manifest driven by `vite.config.ts``SvelteKitPWA({ manifest: {…} })`. Icons declared 192×192, 512×512, 512×512 maskable. `theme_color: #0f172a`, `background_color: #f7f9fb`, `display: standalone`.
- `apps/web/static/icons/``icon-192.png`, `icon-512.png`, `icon-512-maskable.png`, `icon-180.png` placeholder PNGs (slate background + "C" glyph). Generator: `infra/scripts/generate-placeholder-icons.sh` (ImageMagick). Replace with final artwork before public launch — noted in `static/icons/README.md`.
- `apps/web/src/app.html` — iOS PWA meta tags: `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`, `apple-touch-icon`.
## 6.3 Kong rate-limit (invitations only)
- `infra/kong.yml``rest-v1-invitations` route: `POST /rest/v1/collective_invitations`, rate-limit `hour: 20, policy: local, limit_by: header (Authorization)`. `strip_path: true` on a full-table path sends `/` upstream which PostgREST rejects with `PGRST117`, so the route also carries a `request-transformer` plugin that rewrites the URI back to `/collective_invitations`.
- `infra/docker-compose.dev.yml``KONG_PLUGINS` env var gains `rate-limiting` (was missing).
- `packages/test-utils/tests/rate-limit.test.ts` — RL-02 only (21st invitation POST = 429). Gated behind `RUN_RATE_LIMIT_TESTS=1` so it's excluded from `just test-all` (Kong counters are shared state). Run via `just test-rate-limit`, which also `--force-recreate`s Kong to reset counters first.
- **Scope dropped during execution:** Kong rate-limit on `/auth/v1/token` + `/auth/v1/authorize` removed. Reasoning: Keycloak already offers native brute-force protection on the realm (`bruteForceProtected: true`, `maxFailureWaitSeconds: 900`, `failureFactor: 30` in `keycloak/realm-export.json`), and stacking Kong on top throttles legitimate PKCE code exchanges during E2E (the same `/auth/v1/token` endpoint handles both password auth and code exchange — Kong can't distinguish). See `plan/fase-6-deploy-prep.md` §6.3 for the full rationale.
## 6.4 JWT rotation + production env template
- `infra/scripts/rotate-jwt.sh` — bash script signing HS256 `anon` + `service_role` JWTs with a freshly-generated 48-byte secret via `openssl dgst -sha256 -hmac + base64url`. Prints the three values (`SUPABASE_JWT_SECRET`, `PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`) for the operator to paste into `.env`. Does NOT touch files.
- Dev secret rotated as a dry-run of the procedure. `.env` + `apps/web/.env.development` updated together (see `project_env_keys_trap` memory — both files carry `PUBLIC_SUPABASE_ANON_KEY`). Deduped a pre-existing duplicate `PUBLIC_SUPABASE_ANON_KEY=` line in `.env`.
- `.env.production.example` — committed template with placeholders + rotation cadence note.
## 6.5 Lighthouse + RLS audit tooling
- `Justfile``just lighthouse` recipe (prod build → node server on :3000 → `lighthouse --only-categories=pwa,accessibility,best-practices` → HTML report). Requires `just dev` to be up so the real Supabase stack is reachable.
- `Justfile``just rls-audit` delegates to `infra/scripts/rls-audit.sh`.
- `infra/scripts/rls-audit.sh` — signs an Eva JWT (non-member of the seed collective) with `SUPABASE_JWT_SECRET` and GETs eight REST endpoints (`collectives`, `shopping_lists`, `shopping_items`, `task_lists`, `tasks`, `notes`, `collective_members`, `collective_invitations`). Complements `rls-audit.test.ts`; all 8 must return `[]`.

33
docs/history/fase-7.md Normal file
View File

@@ -0,0 +1,33 @@
# Fase 7 — collective-flow E2E coverage (2026-04-14)
12 new Playwright tests + 1 DB fixtures helper + 1 migration. Writing the tests uncovered three product-code bugs which are fixed as part of this phase.
## Tests (all green, included in `just test-e2e`)
- `apps/web/tests/e2e/onboarding.test.ts` — O-01..O-03: Eva auto-redirect to `/onboarding`, happy-path create, empty-name submit-disabled.
- `apps/web/tests/e2e/invitation.test.ts` — I-01..I-05: admin generates token, logged-out-then-accept, logged-in one-click accept, expired + already-used error states.
- `apps/web/tests/e2e/manage-collective.test.ts` — MC-02..MC-05: promote member → admin, demote back, remove member, generate pending invitation.
- `apps/web/tests/fixtures/db.ts``resetEva()` / `seedExpiredInvitation()` / `seedUsedInvitation()` / `restoreSeedMembership()` / `countMembership()`. All via raw SQL (bypasses RLS) so they don't depend on `SUPABASE_SERVICE_ROLE_KEY` being in the Playwright process env.
## Scope dropped from the plan
- **O-04** (multi-collective via a "+ new collective" button in the sidebar): the affordance does not exist in the UI; adding it would be feature work, out of a test-only phase.
- **MC-01** (rename collective): `/collective/manage` has no rename input; same reasoning.
Both are flagged in `plan/fase-7-collective-flow-tests.md` as future UI follow-ups.
## Product-code fixes discovered while writing these tests (all committed with this phase)
1. **`supabase/migrations/012_create_collective_rpc.sql` — atomic `create_collective(name, emoji)` RPC.** Pre-existing onboarding flow did two separate REST calls (`INSERT collectives` chained with `.select().single()`, then `INSERT collective_members`). Both failed: the RETURNING on the first triggered the SELECT policy (`is_member(id)`) which the creator can't pass yet, and the second was gated by `is_admin(collective_id)` against a collective with zero admins. The RPC is `SECURITY DEFINER` and does both inserts atomically. `onboarding/+page.svelte` now calls `supabase.rpc('create_collective', {...})`.
2. **`routes/+layout.svelte` — resume pending invitation after login.** `invitation/[token]/+page.svelte` stashes the token in `sessionStorage.pendingInvitationToken` before triggering OAuth, but nothing read it back. Post-login redirect logic now checks sessionStorage and routes the user back to `/invitation/<token>` instead of defaulting to `/onboarding` or `/lists`.
3. **`routes/(app)/collective/manage/+page.svelte` — reactively reload members.** `onMount` called `loadMembers()` once; if `$currentCollective` was still null at that point (race with the auth listener on a cold navigation), the query short-circuited and the list stayed empty. Now subscribed to `currentCollective` so `loadMembers` re-runs whenever the store resolves.
4. **`routes/invitation/[token]/+page.svelte` — wait for authLoading before deciding.** Racing with the auth listener's first emit caused the page to see `!$isAuthenticated` for a freshly-authenticated user and trigger a redundant Keycloak round-trip. Now awaits `authLoading → false` before branching.
## Test IDs added to UI (stable selectors for Playwright)
- Onboarding: `onboarding-create-tab`, `onboarding-join-tab`, `collective-name-input`, `collective-submit`.
- Manage: `member-row-<uid>`, `role-select-<uid>`, `remove-member-<uid>`, `generate-invite`, `invite-link`.
- Invitation: `invitation-accept`, `invitation-error` (with `data-error-key` attribute carrying the error key for assertion).