Files
collective-lists/CLAUDE.md
Oier Bravo Urtasun 3af1276c15 feat: Fase 2a — shopping lists CRUD
DB: shopping_lists + shopping_items tables with full RLS, pg_cron jobs
(archive completed lists after 7 days, purge trash after 7 days).
item_frequency table with SECURITY DEFINER trigger that normalises and
upserts on every shopping_items INSERT.

Frontend: /lists overview (featured card + 2-col grid + trash toggle),
/lists/[id] detail (qty stepper, inline edit, swipe-to-reveal-delete,
drag & drop reorder via svelte-dnd-action, ItemSuggestions chips).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 22:25:31 +02:00

17 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Code Language

All code must be written in English. This applies to variable names, function names, type names, database column names, SQL identifiers, comments, and any other code artifact. The UI may display text in any language, but the code layer is always English.

Project Status

Fase 0 complete. Fase 1 complete. Fase 2a complete.

  • README.md — full development plan, confirmed tech stack, Justfile reference, and technical warnings
  • analysis/analisis-funcional.md — complete functional specification (domain model, use cases, data models, business rules)
  • plan/fase-*.md — phase-by-phase implementation plans (Fase 0 through Fase 4)

Fase 1 — what has been built

  • supabase/migrations/001_users.sqlusers table, handle_new_user trigger
  • supabase/migrations/002_collectives.sqlcollectives, 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.sqlavatars bucket (private, signed URLs)
  • apps/web/src/lib/supabase.ts — Supabase singleton with PKCE flow
  • apps/web/src/lib/auth.tslogin() / logout() via supabase.auth.signInWithOAuth
  • apps/web/src/lib/stores/auth.tscurrentUser, authLoading, isAuthenticated, displayName
  • apps/web/src/lib/stores/collective.tscurrentCollective, 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)

Fase 2a — what has been built

  • supabase/migrations/005_shopping.sqlshopping_lists, shopping_items, RLS, list_collective_id() helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
  • supabase/migrations/006_item_frequency.sqlitem_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 create input
  • 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

Local Dev: Required /etc/hosts Entry

GoTrue (Supabase Auth) and the browser both must resolve keycloak to reach the Keycloak container. Add this line to /etc/hosts on the development machine:

127.0.0.1 keycloak

Without this, the OAuth redirect URL (http://keycloak:8080/...) built by Keycloak's discovery document is unreachable from the browser.

Planned Stack

Layer Technology
Frontend / PWA SvelteKit 2 + @vite-pwa/sveltekit + Workbox
Auth / IdP Keycloak self-hosted (OIDC/OAuth2)
Backend / BaaS Supabase self-hosted (GoTrue + Realtime + Storage + Kong)
Database PostgreSQL 15 (via Supabase)
Offline storage IndexedDB via idb
Monorepo Turborepo + pnpm workspaces
Infra Docker Compose (dev + prod) + nginx (prod, managed externally)
CI/CD GitHub Actions

Planned Repository Layout

apps/web/src/lib/
  supabase.ts          # Supabase singleton client (PKCE, GoTrue auth)
  auth.ts              # login() / logout() via supabase.auth.signInWithOAuth (Keycloak provider)
  stores/              # Svelte global stores
  sync/                # offline queue + conflict resolution
  components/          # reusable UI components
apps/web/src/routes/   # SvelteKit file-based routes
packages/types/src/
  database.ts          # types generated from Supabase schema (never edit manually)
  domain.ts            # domain types (Collective, ShoppingList, Item, etc.)
supabase/
  migrations/          # versioned SQL migrations
  seed.sql             # dev test data (5 Keycloak users + sample collective)
  functions/           # Edge Functions (invitations, etc.)
  config.toml          # Supabase CLI config + Keycloak OIDC
keycloak/
  realm-export.json    # "colectivo" realm — imported on dev startup
infra/
  docker-compose.dev.yml
  docker-compose.prod.yml
  scripts/backup.sh / backup-cron.sh / restore.sh / deploy.sh

Development Commands (Justfile)

just setup            # first-time (and idempotent) local environment setup
just dev              # start docker-compose.dev.yml + SvelteKit dev server
just serve            # build + start prod Node server at :3000 against dev Docker stack
just stop             # stop all services (Docker stack + Vite dev server)
just dev-stop         # stop Docker stack only
just dev-clean        # stop Docker stack and remove all volumes (full reset)
just db-reset         # drop + migrate + seed (dev)
just db-migrate       # apply pending migrations (dev)
just db-seed          # apply supabase/seed.sql to the running dev db
just db-types         # regenerate TS types from Supabase schema → packages/types
just kc-export        # export Keycloak realm → keycloak/realm-export.json
just kc-open          # open Keycloak Admin Console (localhost:8080)
just deploy           # run infra/scripts/deploy.sh (prod)
just backup supabase           # immediate manual backup
just restore supabase <file>   # restore a specific dump
just logs             # docker compose logs -f (prod)

Local Dev Endpoints and Credentials

Service URL Credentials (dev only)
SvelteKit app http://localhost:5173
Supabase Studio http://localhost:54323
Supabase API (Kong) http://localhost:8001 apikey: PUBLIC_SUPABASE_ANON_KEY from .env
Keycloak Admin http://localhost:8080/admin admin / admin
PostgreSQL localhost:5432 postgres / postgres

Dev secrets (set in .env, never commit to prod):

Variable Dev value
POSTGRES_PASSWORD postgres
SUPABASE_JWT_SECRET super-secret-jwt-token-with-at-least-32-characters-long
KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD admin / admin
KEYCLOAK_CLIENT_SECRET gotrue-dev-secret (client secret for colectivo-web in GoTrue)

Domain Model

The central organizing unit is the Collective (household group), not the individual user. All content belongs to the Collective.

Roles: admin (full CRUD + member management) | member (full CRUD on content) | guest (read-only)

Core entities: CollectiveCollectiveMember, ShoppingListShoppingItem, TaskListTask, Note, CollectiveInvitation

Key field names (English): collective_id, list_id, is_checked, checked_by, checked_at, created_by, created_at, completed_at, sort_order, display_name, avatar_type, avatar_emoji, avatar_url, language

Key business rules:

  • Only one active shopping session per list at a time.
  • Completing a list keeps items as history; it can be "reset" (uncheck all → back to active).
  • Trash retains deleted items/notes for 7 days before permanent deletion.
  • If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion.

Auth Architecture (Keycloak → GoTrue → Supabase)

Strategy: Option B — GoTrue as OIDC proxy. The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses auth.uid() which resolves to the GoTrue user UUID (mapped from Keycloak sub). GoTrue maintains auth.users.

Key decisions:

  • Self-registration enabled in Keycloak — users can create accounts on the Keycloak login screen.
  • Invitations are link-only — no email sent. Admin generates a link and shares it manually.
  • Multiple collectives per user — a user can belong to several collectives and switch between them in the sidebar.

Logout is a single call — supabase.auth.signOut() — which clears the GoTrue session. Keycloak's own SSO session persists (user won't be prompted for credentials on next login until the Keycloak session expires), which is acceptable for this app.

Non-obvious integration requirements (all implemented; do not remove):

  1. colectivo-web is a confidential client — GoTrue needs a client secret to exchange codes with Keycloak. GOTRUE_EXTERNAL_KEYCLOAK_SECRET must match Keycloak's client secret (gotrue-dev-secret in dev).

  2. Custom openid client scope in Keycloak — GoTrue v2.158.1 sends scope=profile email (no openid) to Keycloak, ignoring GOTRUE_EXTERNAL_KEYCLOAK_SCOPES. Without openid in the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope named openid with include.in.token.scope=true is set as a default scope on colectivo-web, so Keycloak injects openid even when not requested.

  3. GOTRUE_DISABLE_SIGNUP: "false" — GoTrue's signup flag blocks OIDC-based user creation too. Must be false; Keycloak is the gatekeeper for who can register.

  4. auth.users pre-seeded with Keycloak UUIDs — GoTrue generates its own UUIDs on first login. seed.sql pre-inserts both auth.users (with instance_id='00000000-0000-0000-0000-000000000000' and empty-string token fields) and auth.identities (provider=keycloak, provider_id=Keycloak sub). This ensures auth.uid() = seed UUID = FK in all public tables.

  5. Kong env var substitution via kong-start.sh — Kong 2.8 does not interpolate ${VAR} in declarative config files. infra/kong-start.sh runs perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge' on kong.yml before starting Kong. Without this, Kong loads the literal string ${SUPABASE_ANON_KEY} as the API key, rejecting all requests.

SvelteKit + Supabase auth gotchas (do not remove these patterns):

  1. Do not call getSession() in a SvelteKit load function to gate auth. Supabase JS v2 initialises its session from localStorage asynchronously (_recoverAndRefresh). In a load function that runs immediately on mount, getSession() can return null for a valid session before initialisation completes — causing login() to fire and redirect to Keycloak unnecessarily. The correct pattern: (app)/+layout.ts only sets ssr: false, and (app)/+layout.svelte uses a $effect that redirects when !$authLoading && !$isAuthenticated. This waits for onAuthStateChange to fire (which is authoritative).

  2. onAuthStateChange fires INITIAL_SESSION on page load, not SIGNED_IN. SIGNED_IN only fires immediately after a login flow. Load collective data whenever the session is present, regardless of event type — otherwise collectives are never loaded on page refresh. Pattern in root +layout.svelte: if (session) { await loadUserCollectives(...) } covering INITIAL_SESSION, SIGNED_IN, and TOKEN_REFRESHED.

  3. CSS custom properties use RGB triplets — always use rgb(), never hsl(). All design tokens in app.css store values as space-separated RGB triplets (e.g. 51 65 85). Using hsl(var(--token)) interprets the first value as a hue degree — hsl(51 65% 85%) renders as light yellow, not slate-700. The Tailwind config and any background-color/color declarations in app.css must use rgb(var(--token) / <alpha>).

  4. PWA service worker: do not use injectManifest with a file named service-worker.ts. SvelteKit intercepts any file at src/service-worker.[jt]s and enforces a hard restriction: only $service-worker and $env/static/public may be imported — Workbox modules are blocked. With injectManifest, @vite-pwa/sveltekit tries to find the compiled SW output at .svelte-kit/output/client/service-worker.js, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 12a: use generateSW strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file src/sw.ts — SvelteKit does not recognise it as a service worker — and set strategies: 'injectManifest', filename: 'sw.ts' in vite.config.ts.

Sync Strategy

Content Mechanism Target latency
Items in active shopping session WebSocket (Supabase Realtime) < 1 second
Lists, tasks (outside session) SSE / polling < 5 seconds
Notes Polling < 30 seconds

Offline-first: all operations execute locally first (IndexedDB), then sync in background. Conflict resolution is last-write-wins. Conflicts are logged to a sync_conflicts table for debugging — no CRDT in MVP.

Critical Platform Gotchas

iOS Safari / PWA:

  • The app uses Supabase OAuth PKCE flow (signInWithOAuth({ provider: 'keycloak' })). No keycloak-js, no iframe-based silent refresh — Safari-compatible by design.
  • GoTrue session is persisted in localStorage and auto-refreshed by the Supabase client. No manual updateToken() needed.
  • Background Sync API is not supported in Safari. Implement a manual fallback using the online event.
  • Push notifications require iOS 16.4+ and the PWA must be installed to the home screen.
  • Test the shopping session mode on a real iPhone during development, not only in DevTools.

nginx (prod) — WebSocket for Realtime:

  • The /realtime/ location block must come before the / block.
  • proxy_read_timeout 3600s is required on the Realtime block — without it, nginx closes WebSocket connections after 60 seconds, forcing continuous reconnects during active shopping sessions.
  • CSP connect-src must include both https:// and wss:// for the API domain.

Keycloak behind nginx:

  • X-Forwarded-Host and X-Forwarded-Port headers are mandatory. Without them, Keycloak builds redirect URIs with the internal port (8080) instead of 443, breaking the OIDC flow.

Supabase Realtime self-hosted:

  • Check MAX_REPLICATION_SLOTS in PostgreSQL and REALTIME_MAX_CONNECTIONS before going to production.
  • Presence subscriptions are more resource-intensive than Postgres Changes — limit them to lists with an active session.

Dev Test Users

Defined in keycloak/realm-export.json and supabase/seed.sql. All use password test1234.

User Email Role in test collective
ana ana@dev.local Admin
borja borja@dev.local Member
carmen carmen@dev.local Member
david david@dev.local Guest
eva eva@dev.local (no collective — for onboarding testing)

Internationalisation

Library: Paraglide JS (@inlang/paraglide-sveltekit). Compile-time, zero runtime overhead, tree-shaken per language.

  • Message files: messages/en.json (default) and messages/es.json
  • Language detection order: users.language (if logged in) → Accept-Language header → en
  • Language switching: update users.language in Supabase + call setLanguageTag() — no page reload
  • Never hardcode visible user-facing strings in components. All UI text goes through Paraglide messages.

Supported languages: en, es.

TypeScript Types

packages/types/src/database.ts is generated by just db-types (supabase gen types typescript). Never edit it manually. Domain-level types go in packages/types/src/domain.ts.