Commit Graph

16 Commits

Author SHA1 Message Date
286f59225f feat(fase-12): nav gating + redirect guard + realtime publication
UI side:
  * DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
    tag each entry with data-section, and BottomTabBar exposes a
    data-section-count attribute so the grid is observable from tests.
  * "lists" is force-shown unconditionally — the §12.3.4 always-one-
    landing rule covers the edge case where every layer says OFF.
  * (app)/+layout.svelte adds a $effect that, when the URL matches a
    disabled section (/tasks, /notes, /search), goto's /lists and shows
    a transient `section_disabled_for_collective` toast.
  * Root +layout.svelte exposes the supabase singleton on window.__sb
    in dev so the new Playwright spec can patch rows without a parallel
    client; dead-code-eliminated in production builds.

DB side:
  * Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
    public.users + public.collectives + REPLICA IDENTITY FULL on both.
    Without this membership the features.ts subscriptions get zero
    events and SV-02 (collective toggle → realtime → member's nav
    updates) silently fails. Caught while running the spec.
  * pgTAP 015 grows from 16 to 20 assertions to cover publication
    membership + REPLICA IDENTITY for both new realtime tables.

Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.

Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
  SV-01 user toggle → nav reflects → /tasks redirects to /lists
  SV-02 admin collective toggle → member sees it disappear in realtime
  SV-03 collective ON beats user OFF — per-collective resolution

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:10:19 +02:00
59a32f80f7 feat(fase-12): section visibility model — feature_flags + section_enabled()
Adds JSONB `feature_flags` columns on public.users and public.collectives
(default '{}') plus two SQL helpers:

  * public.known_sections() — IMMUTABLE, returns the canonical 4 sections
    (lists, tasks, notes, search). Adding a section is a fresh migration.
  * public.section_enabled(section, user, collective) — STABLE, returns
    boolean. Precedence is COLLECTIVE > USER > default TRUE, evaluated as
    a single COALESCE so Fase 13 can prepend a server layer as a one-line
    diff. Unknown section keys default TRUE (forward-compat).

No new RLS policy: writes to feature_flags piggyback on users_update_own
and collectives_update (migration 003). 16 new pgTAP assertions verify
column shape, function signatures, volatility, and precedence semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:47:52 +02:00
4c3552fb3c feat(fase-11): item_tags + shopping_item_tags schema (11.1)
Migration 022 adds two new tables for collective-scoped item tags plus the
many-to-many bridge to shopping_items. Tags carry a colour from an 8-preset
palette enforced by CHECK. RLS uses is_member for SELECT (guests can see)
and is_active_member for writes (guests are read-only). A new STABLE helper
item_collective_id(uuid) resolves the parent collective in a single function
call so the shopping_item_tags policies do not chain two STABLE lookups.

Both tables join the supabase_realtime publication with REPLICA IDENTITY
FULL, matching the shape of 007 for shopping_items / shopping_lists.

pgTAP 014 covers schema invariants (publication, replica identity, unique
+ CHECK constraints, cascade behaviour for both tag delete and collective
delete). Per-role RLS semantics will be covered by the Vitest integration
suite in the next commit — pgTAP runs as postgres which bypasses RLS.

Types: ItemTag, ItemWithTags + ItemTagColor in domain.ts; item_tags,
shopping_item_tags, item_collective_id added by hand to the curated
database.ts (the supabase CLI is not installed locally; just db-types is
gated and skips with a notice).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:01:52 +02:00
92ad29696d feat(fase-10): account hard-delete — FK relax + RPC (10.5)
Migration 019 normalizes every \`created_by\` / \`updated_by\` FK from
\`NOT NULL REFERENCES users(id) ON DELETE RESTRICT\` to
\`NULL REFERENCES users(id) ON DELETE SET NULL\`. Affected tables:
collectives, collective_invitations, shopping_lists, shopping_items,
task_lists, tasks, notes (created_by + updated_by). Without this,
delete_account() would be blocked the first time the user creates any
content. Spec §6.3: content survives the user; the row is orphaned with
the FK set to NULL (UI shows "Deleted user" — to be polished later).

Migration 021 adds delete_account():
- two-step delete: public.users (fires the existing promote-oldest-
  admin trigger from migration 002, then CASCADEs collective_members
  and SET-NULLs content FKs), then auth.users (CASCADEs sync_conflicts
  + drops the GoTrue identity row + token rows)
- guard: errcode P0003 when the caller is the sole admin of a
  collective where every other member is a guest (nobody promotable);
  the user must promote someone manually first
- Keycloak is NOT touched (documented limitation in the UI body); the
  user can re-register with the same email and will receive a fresh
  UUID-distinct profile

Settings UI (already shipped with 10.1) wires the modal: explicit body
listing what is and isn't deleted; confirm button only enables when the
user types DELETE exactly. Post-success calls logout() so the Keycloak
SSO cookie is ended too.

pgTAP 013 (8 assertions): RPC exists, regular user delete + cascade +
content-orphan, sole-admin-with-promotable promotes, sole-admin-with-
only-guest blocked with P0003. Playwright DEL-01 covers the
type-the-word UI guard; full "delete + re-login fails" is left to
pgTAP because seeding ephemeral Keycloak accounts in E2E would
contaminate every downstream suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:21:25 +02:00
5b4ba9aaef feat(fase-10): trash drawer — restore + hard-delete RPCs (10.4)
Migration 020 (019 reserved per plan) introduces two SECURITY DEFINER
RPCs that back the trash drawer's per-row actions:

- restore_list(uuid): clears deleted_at; requires caller to be admin
  or member of the owning collective; rejects if the list is not
  currently in trash (defensive)
- hard_delete_list(uuid): permanently deletes a soft-deleted list;
  same role guard; rejects if the list is still active

Both close the existing UI gap where guest could in principle issue
the equivalent UPDATE (the FROM-UPDATE policy is is_active_member,
which excludes guest, but the surface area is broader than these two
intents — the RPCs make the contract explicit).

The lists drawer already exposed the Restore + Delete-for-ever
buttons; only the store calls swap from chained PostgREST writes to
.rpc() — the UI itself is unchanged.

pgTAP 012 (8 assertions) covers existence, restore happy path,
non-member rejection, hard-delete happy path, active-list guard.
Integration T-10..T-14 (5 vitest) verifies the wire format the store
relies on (rpc errors, row state, active-listing visibility).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:14:10 +02:00
83f613ec1e feat(fase-10): CU-H08 dissolve collective — RPC + danger-zone UI
Migration 018 adds public.dissolve_collective(uuid):
- admin-only; non-admin (member/guest/non-member) → errcode P0002
- single DELETE from public.collectives; all child tables cascade
  (collective_members, collective_invitations, shopping_lists →
  shopping_items, task_lists → tasks, notes, item_frequency,
  sync_conflicts — verified by audit, no schema change needed)

/collective/manage gains a "Danger zone" card (admin-only) with a
Dissolve button. The confirmation modal loads live counts of lists,
tasks and notes, displays them in the warning, and requires the user
to type the collective name exactly before the confirm button is
enabled. On success, locals stores drop the collective and the user
is sent to /lists (next collective) or /onboarding (none left).

pgTAP 011 (8 assertions): RPC exists, member/guest/non-member all
rejected with P0002, admin succeeds, collective row gone, child
list cascade-deleted, members cascade-deleted. Playwright D-01..D-03
cover the happy path, the type-the-name guard, and member visibility.

Auxiliary collective fixture (NOT the seed) per test — the seed
collective must survive every run for downstream suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:11:21 +02:00
2f0847a5a3 feat(fase-10): CU-H06 leave collective — RPC + settings UI
Migration 017 adds public.leave_collective(uuid) with three branches:
- normal member: remove membership row
- sole admin with other members: promote oldest-joined remaining member
  to admin (inline; the existing user-delete trigger does not cover the
  membership-delete path), then remove the row
- sole member: reject with errcode P0001 so the UI can direct the user
  to the dissolve flow (CU-H08)

Settings page gains a "Your collectives" section listing the user's
memberships with a per-row Leave button; the confirmation modal calls
the RPC, drops the collective from local stores, and either switches
to the next collective or sends the user to /onboarding when none
remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds
all message keys consumed by 10.1, 10.3, 10.5 and 10.6.

pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote,
sole-member rejected with P0001. Playwright L-01 walks Borja through
the UI flow + checks the seed collective survives (content stays).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:04:43 +02:00
ed556ce245 feat(fase-9): wire sync_conflicts table + queue logging + debug panel
Closes 9.3 in full: migration 016 + pgTAP test 010 + queue.ts
conflict-detection path + /settings debug panel + integration test.

- Migration 016 adds public.sync_conflicts (append-only audit log of
  last-write-wins conflicts surfaced by the offline mutation queue).
  RLS allows owners to SELECT + INSERT their own rows only; UPDATE
  and DELETE have no policy and are silently denied. collective_id
  is intentionally nullable so user-level conflicts (theme, language
  in future) also fit. Indexed on (user_id, created_at desc) for the
  "show me my last 20 conflicts" query.
- queue.ts gains a `QueueContext` (currentUserId + collectiveId) and
  an optional `preImage` field on UpdateOp. Before sending an UPDATE
  the queue fetches the remote row (limited to the patched fields),
  proceeds with the UPDATE (last-write-wins), then fires a best-effort
  INSERT into sync_conflicts when the remote diverged. Failure to log
  never blocks the UPDATE or pollutes the pending_ops queue. Three
  unit specs (SC-01..SC-03) lock the contract in.
- sync/index.ts exposes setSyncContext(), called from (app)/+layout's
  $effect whenever currentUser / currentCollective change.
- New SyncConflictsPanel.svelte renders the last 20 conflict rows
  for the current user with an "Export JSON" button. Gated on
  import.meta.env.DEV (or a forceShow prop for a future secret
  unlock) — never ships in the production bundle. Slotted into
  /settings just above the Account section.
- Integration tests (packages/test-utils/tests/sync-conflicts.test.ts,
  4 SC-INT specs) exercise the real PostgREST + RLS contract end-to-end
  with the existing seed users (Ana, Borja). Requires fake-indexeddb in
  the test-utils dev deps (queue's IDB shim).
- pgTAP test 010 covers structure, RLS reads/writes, ownership rejection,
  and the append-only invariant (UPDATE/DELETE return 0 rows).
- packages/types/src/database.ts adds the sync_conflicts table type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:31:35 +02:00
3efe0b9be0 feat(fase-9): migration 015 — users.theme column for dark mode
Adds `theme text not null default 'system' check (theme in
('light','dark','system'))` to public.users. New users get 'system' so
the UI follows the OS preference until they opt in; existing rows
backfill via the default.

Includes pgTAP test 009 (8 assertions covering column existence/type,
default value, valid transitions, and the check constraint rejecting
unknown values). Wires the theme field into the hand-curated
database.ts type so PostgREST queries stay typed.

Also hardens `just db-types` to bail out (instead of truncating
database.ts) when the supabase CLI is missing — the recipe was silently
wiping the file on this dev machine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:06:18 +02:00
ca12516005 fix(auth): RP-initiated Keycloak logout + PKCE verifier race
Clicking logout previously only called supabase.auth.signOut(): Keycloak's
SSO cookie was untouched, so the (app) layout's $effect immediately
re-fired login() and Keycloak silently re-authenticated — logout looked
broken. That same chain also surfaced "PKCE code verifier not found in
storage" on prod, since signOut()'s async storage clear could race
signInWithOAuth()'s verifier write, or the $effect could double-fire and
overwrite verifiers mid-flight.

- logout(): signOut() then redirect to Keycloak's end_session endpoint
  (client_id + post_logout_redirect_uri=/logged-out). Keycloak shows a
  one-click "Do you want to log out?" confirmation because GoTrue's proxy
  flow does not expose the id_token, so we can't pass id_token_hint.
- isLoggingOut flag + guard in (app) layout $effect: prevents auto-relogin
  during the logout navigation.
- New public /logged-out route, outside the (app) group.
- A-05 rewritten, new A-07 (fresh login must prompt for credentials after
  RP-initiated logout).
- pgTAP 008 extended with 4 assertions for migration 014's UPDATE trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:35:17 +02:00
2294a31942 fix(auth): default empty role/aud on auth.users → 'authenticated'
Found on ambrosio after the first real self-registration: GoTrue v2.158.1
persists OIDC-provisioned users with empty-string role and aud. PostgREST
then dies with `role "" does not exist` on every REST call because its
per-request `SET LOCAL role = $claim` can't resolve ''. Dev never caught
this because seed.sql hardcodes both columns.

- supabase/migrations/013_auth_users_role_default.sql: backfill existing
  rows + BEFORE INSERT trigger on auth.users that fills empty/NULL role +
  aud with 'authenticated'. Idempotent, preserves explicit values.
- supabase/tests/008_auth_user_role_default.sql: pgTAP coverage — function
  + trigger exist, backfill left nothing empty, empty-string and NULL
  values get defaulted, explicit non-empty role is preserved.
- CLAUDE.md: status line updated to 255 tests (was 248), new gotcha #19
  documenting the GoTrue quirk + that users in existing sessions must
  re-login once for a fresh JWT after the fix lands on any given env.

Total: 41 pgTAP (was 34) + 140 integration + 15 unit + 58 Playwright + 1
gated rate-limit. Full `just test-all` green.
2026-04-15 00:23:00 +02:00
d6cea5146f feat(fase-5.10.0): drop shopping_items.unit column
The unit field had no edit path under the redesign (only name + qty
remain in the row; the double-tap overlay is name-only). Users can encode
the unit inside the name when it matters — e.g. "Milk 1L", "Eggs 1 doz".

Changes
  supabase/migrations/011_drop_shopping_items_unit.sql — DROP COLUMN
  supabase/seed.sql — unit values folded into names where relevant
  supabase/tests/002_item_frequency_trigger.sql — remove unit from INSERT
  packages/types — remove unit from ShoppingItem Row/Insert/Update
  apps/web/src/lib/stores/lists.ts — addItem/updateItem signatures
  apps/web/src/routes/(app)/lists/[id]/+page.svelte — purge newUnit /
    editUnit state + inputs + display
  apps/web/src/routes/(app)/lists/[id]/session/+page.svelte — simplify
    quantity-only render
  apps/web/messages/{en,es}.json — remove list_unit_label

Verification
  just test-db  → 34 pgTAP green
  just test-integration → 140 + 2 skipped
  just test-unit → 11 green
  just test-e2e → 39 + 2 skipped

The generated search tsvector on shopping_items.search already indexes
only `name`, so full-text search coverage is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:00:08 +02:00
2806e06db2 feat(fase-4): global search + RLS isolation audit (211 tests green)
4.0 Tests primero
  Vitest: search.test.ts (10 — S-01..S-04), rls-audit.test.ts (42 —
    3 intruders × 14 cross-collective ops proving zero leakage)
  pgTAP: 007_search_tsvector.sql (9 — columns, GIN indexes, function
    signature, generated-vector invariant)
  Playwright: search.test.ts (2 — SR-01 happy-path, SR-02 filter toggle)

4.1 Búsqueda global
  Migration 010_search_vectors.sql: STORED GENERATED tsvector columns
    on shopping_items/tasks/notes + GIN indexes + search_result_type
    enum + search_in_collective() SECURITY INVOKER function (RLS-aware,
    trash-excluded per RN-08) + GRANT EXECUTE to anon/authenticated.
    Uses `simple` config (no stemmer — bilingual en/es).
  Store apps/web/src/lib/stores/search.ts — RPC wrapper
  /search: debounced input (300ms, ≥2 chars), type filter chips with
    multi-toggle, results grouped per type with data-testid hooks for
    Playwright, deep-link per type (shopping_item → /lists/[id], task →
    /tasks/[id], note → /notes/[id])

4.4 Security
  rls-audit.test.ts replaces the curl-based manual audit with a
    parametrised matrix — zero cross-collective reads/writes under
    three different attacker roles

Realtime flake hardening
  Add 250ms settle after SUBSCRIBED in realtime-helpers.ts — the Phoenix
    socket reports SUBSCRIBED slightly before the backend finishes
    propagating the filter to the WAL subscription, so mutations fired
    immediately after subscribe could miss the filter under load.

4.Z Verification
  just test-all → 211 verdes, 2 skipped
    34 pgTAP (was 25, +9 search)
    140 Vitest integration (was 88, +10 search +42 rls-audit; 2 skipped)
    6 Vitest unit (unchanged)
    31 Playwright (was 29, +2 search)
  Deferred (prod-deploy scope):
    PWA install/push (needs real iOS/Android hardware)
    Kong rate-limit plugin config
    JWT_SECRET / ANON_KEY rotation
    Lighthouse PWA ≥ 90 manual validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:47:38 +02:00
104eeba02e feat(fase-3): Tareas + Notas — TDD complete (148 tests green)
3.0 Tests primero
  pgTAP: 005_tasks_rls.sql (5 — completion CHECK), 006_notes_trash_purge.sql (4 — purge SQL inline + pin/archive CHECK)
  Vitest RLS: rls-tasks.test.ts (14), rls-notes.test.ts (15)
  Playwright: tasks.test.ts (3), notes.test.ts (4)

3.1 Tareas
  Migration 008_tasks.sql: task_lists + tasks + task_list_collective_id() helper
  CHECK constraint: is_completed ⇔ completed_by ⇔ completed_at
  RLS by role + cross-collective isolation
  Store apps/web/src/lib/stores/tasks.ts (optimistic CRUD + reorder)
  /tasks overview (grid + sticky create input + 5s polling)
  /tasks/[id] detail with svelte-dnd-action reorder, flip animation,
    inline edit (dblclick), "Completed by …" attribution via lazy-loaded
    collective_members

3.2 Notas
  Migration 009_notes.sql: notes + note_color enum (8) + pin/archive CHECK
    + 7d trash window in RLS + fn_notes_touch trigger + pg_cron purge job
  Store apps/web/src/lib/stores/notes.ts (CRUD + pin/archive/trash/duplicate)
  /notes board (pinned section testid notes-pinned + 30s polling)
  /notes/[id] editor (title + textarea, 500ms debounced autosave,
    color picker, pin/archive/duplicate/trash actions)
  /notes/archive (restore + send to trash)
  /notes/trash (restore + permanent delete)

3.Z Verification
  just test-all → 148 verdes, 2 skipped:
    25 pgTAP (was 16, +9)
    88 Vitest integration (was 59, +29; 2 presence skipped)
    6 Vitest unit (unchanged)
    29 Playwright (was 22, +3 tasks +4 notes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:28:19 +02:00
4c913cf495 feat(realtime): Fase 2b.0 infra + first R-series tests green
Enable postgres_changes broadcasts on shopping_items / shopping_lists and prove
the path end-to-end with Vitest Realtime tests and a pgTAP configuration check.

Infra changes that were silently blocking events
- Migration 007: shopping_items + shopping_lists added to supabase_realtime
  publication with REPLICA IDENTITY FULL (UPDATE/DELETE payloads need the full
  row so list_id-based filters work and RLS can evaluate DELETE against OLD)
- Realtime service: DB_USER=supabase_admin (superuser; supabase_replication_admin
  lacks CREATE on the `realtime` schema that the service auto-creates per tenant)
- Realtime service: SELF_HOST_TENANT_NAME=realtime so the seed tenant name
  matches the default supabase-js resolves for localhost URLs (was realtime-dev
  → TenantNotFound)

Tests
- pgTAP 004_realtime_publication.sql — P-01..P-04: publication membership and
  REPLICA IDENTITY FULL for both shopping tables
- Vitest realtime-postgres-changes.test.ts — R-01 INSERT broadcast,
  R-02 UPDATE carries full row, R-03 list_id filter isolates events
- test-utils realtime-helpers.ts — subscribePostgresChanges() returns a
  waitFor(predicate, timeout) helper that captures the SUBSCRIBED handshake
  before returning so mutations issued right after are not lost
- createClientAs now calls realtime.setAuth(token) so the server evaluates
  RLS against the test user's JWT
- Justfile test-db now globs supabase/tests/*.sql so new pgTAP files are picked
  up automatically

Totals: 54→57 Vitest, 12→16 pgTAP, 15 Playwright = 88 tests green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:33:40 +02:00
f396897cb5 test: full test suite (Vitest + pgTAP + Playwright) + TDD plan restructure
Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.

Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
  each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
  promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
  caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
  as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain

Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
  auth lock (getAccessToken → getSession → initializePromise waits on the same
  lock that's held during event dispatch). Fix is two defenses: a pass-through
  lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
  push loadUserCollectives out of the callback's microtask chain. Either alone
  is insufficient; both together unblock the Playwright suite.

Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
  secret than root .env; Vite inlined that into the browser bundle so Kong (which
  uses the root .env value) rejected every request with 401. Aligned the two
  files and added a memory entry to flag this for the next rotation.

Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
  final. Completed fases (0, 1, 2a) show the pattern retroactively with the
  tests that currently cover them; pending fases (2b, 3, 4) list the tests to
  write before implementation.

Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
  #11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:40:16 +02:00