# FamChore v2 — Architecture & Developer Reference ## Overview Multi-tenant chore tracking SaaS. Families ("fams") are isolated tenant groups. Admins (parents) authenticate via email/password; members (children) join via invite OTP and get a server-derived password. No `members` collection — everyone is a `users` row scoped by `famId`. **Source of truth:** `AGENTS.md` is the living reference. This doc captures the architecture. --- ## 1. Stack | Component | Role | Deploy | Port | |---|---|---|---| | SvelteKit | SSR frontend, all UI + Stripe server routes | Coolify Docker (nginx) | :2080 internal, :3001 external | | Hono proxy | data layer (`/api/*`); CRON | Same container, proxied via nginx `/api/*` | :3456 internal | | PocketBase | DB, auth, realtime, storage, Admin UI | Coolify service (pb.chores.app.com) | :8090 | | Stripe | subscriptions (in SvelteKit, NOT Hono) | — | — | ### Deployment Topology ``` chores.app.com ────┬──► nginx (:3001) │ ├── /* ──► SvelteKit (:2080) │ └── /api/* ──► Hono proxy (:3456) │ pb.chores.app.com ──► PocketBase (:8090) │ Admin UI at /_ │ Volume: /pb_data (persistence + backups) │ stripe.com ─────────► SvelteKit /account/webhook ``` --- ## 2. Auth Model ### 2.1 Roles & Methods | Role | Auth | Session | PB Entity | |---|---|---|---| | **Admin (parent)** | PB email + password | 24hr JWT `pb_token` cookie | `users` (role `parent`) | | **Member (child)** | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) | | **Superuser** | PB `_superusers` (server-side only) | — | `_superusers` | ### 2.2 Details - **Admins** are `users` (role `parent`). Login via email/password → httpOnly `pb_token` cookie `{ id, name, username, role: "parent", famId, color }`. - **Members** are `users` (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`). Join gated by a 20-min OTP in `otp`, then `authWithPassword`. Same httpOnly `pb_token` cookie. No `members` collection. - **Superuser** used only server-side by `pb-admin.ts` for cross-family queries (`/admin`) and OTP/signup writes. - Layout `[fam]/+layout.server.ts` derives `isParent`/`role` centrally from the session. `pb_token` is httpOnly, so the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in `onMount`. ### 2.3 PB Auth Rules Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.famId`; family-scoped collections have public `listRule`/`viewRule` so reads/subscribes work for both roles. Superuser bypasses via admin API. --- ## 3. Data Model (PB collections, all scoped by `famId`) - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) - `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour on `users.color`) - `fams` — name, slug, stripeCustomerId, featureFlags - `chore_templates` — famId, name, defaultValue, defaultFrequency - `assigned_chores` — famId, userId, templateId, frequency, value - `completions` — famId, userId, assignedChoreId, date - `weekly_history` — famId, userId, weekStart, pointsEarned, moneyEarned - `rewards` — famId, userId, source, label, value, claimed, claimedAt, claimable, settleDate - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl > **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` bootstraps a fresh/wiped PB (idempotent). The native `users` auth fields/rules + superuser-only `otp` are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`). Data is disposable — schema change = update `SCHEMA_PLAN` + wipe PB + reboot. --- ## 4. Routes ### SvelteKit ``` / Landing page (SaaS marketing) /admin Platform super-admin stats dashboard /login · /logout Parent login / logout /signup Parent + family signup /{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code= /{fam} Fam dashboard /{fam}/{username} Parent → admin overview, Child → member kanban /{fam}/{username}/chores Chore templates & assignment grid /{fam}/{username}/ledger Rewards / chores / todos ledger /{fam}/{username}/bonuses Bonus configs & evaluation /{fam}/{username}/preferences User preferences /{fam}/{username}/settings Family admin settings (parent only) — Stripe connect/manage + pause /account Account/billing — payment setup & subscription management /subscriptions 3-tier plan page (trial | monthly | yearly), access via settings /account/webhook Stripe webhook handler (server route) /api/* Hono proxy (data layer; CRON not implemented) ``` --- ## 5. Data Flow ### 5.1 Reads (both roles) - `famStore.init()` fetches all collections via PB SDK, authenticated via the seeded `pb_token`. `.subscribe()` works for both roles. - TopNav season pills read from `famStore.seasons` (reactive). ### 5.2 Writes - **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer `). - **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`). - **Member updates:** Browser → Hono proxy → PB (`Bearer `). - **Reward creation:** after completion toggle, Hono proxy creates reward if threshold met. - **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) not implemented. - **Stripe:** SvelteKit server routes `/account` + `/account/webhook` (frontend app, NOT Hono). - **WhatsApp:** not implemented. ### 5.3 Stripe Subscription (embedded Checkout) ``` Parent picks a tier on /subscriptions (trial | monthly | yearly) → SvelteKit server action (subscriptions) creates Embedded Checkout Session createEmbeddedCheckoutSession() → ui_mode: "embedded" → client_secret → returns { clientSecret } to the browser → @stripe/stripe-js createEmbeddedCheckoutPage({ clientSecret }) mounts in-page → Parent completes payment inside the embedded Stripe page → Stripe sends checkout.session.completed → SvelteKit /account/webhook handleStripeEvent() → pbAdmin.update fams.stripeCustomerId + active = true → Subsequent customer.subscription.* webhooks keep fams.active in sync → Parent returns to /account?checkout=return ``` Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (writes `fams.active` directly). ### 5.3.1 Payments architecture ``` ┌────────────────────────────────────────── SVELTEKIT APP ──────────────────────────────────────────┐ │ │ │ Browser │ │ ┌──────────────────────────────┐ POST ?/checkout ┌─────────────────────────────────────────┐ │ │ │ /subscriptions (+page.svelte)│ ───────────────────► │ subscriptions/+page.server.ts (action) │ │ │ │ • tier cards │ │ • resolves famId + parent email (PB) │ │ │ │ • createEmbeddedCheckoutPage│ ◄─── clientSecret ─── │ • createEmbeddedCheckoutSession() │ │ │ │ • mounts embedded Stripe UI │ └───────────────┬─────────────────────────┘ │ │ └──────────────┬───────────────┘ │ stripe SDK (secret) │ │ │ createEmbeddedCheckoutPage(clientSecret) ▼ │ │ ▼ ┌─────────────────────────────┐ │ │ ┌──────────────────────────────┐ │ STRIPE API │ │ │ │ Embedded Checkout (Stripe │ card + pay │ checkout.sessions.create │ │ │ │ hosted iframe, in-page) │ ───────────────────► │ (ui_mode: embedded) │ │ │ └──────────────────────────────┘ └──────────────┬──────────────┘ │ │ │ webhook events │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ /account/webhook (+server.ts) │ │ │ │ • verify stripe-signature (CLI secret in dev, dashboard in prod) │ │ │ │ • handleStripeEvent() → stripe-events.ts │ │ │ │ └ checkout.session.completed → fams.stripeCustomerId + active = true │ │ │ │ └ customer.subscription.* → fams.active (sync by customer id) │ │ │ └──────────────────────────────────────────────────────┬─────────────────────────────────────┘ │ │ │ pbAdmin (superuser) │ └─────────────────────────────────────────────────────────┼─────────────────────────────────────────┘ ▼ ┌────────────────────┐ │ POCKETBASE │ │ fams.stripeCustomerId │ │ fams.active (bool) │ └────────────────────┘ Management: /account (+page.server.ts) • billing action → createBillingPortalSession(customerId) → Stripe Customer Portal (update card, cancel / reactivate subscription) • togglePause action → pbAdmin.update fams.active (hard pause, independent of Stripe) Dev-only: stripe CLI: stripe listen -e ... --forward-to http://127.0.0.1:2080/account/webhook (sets STRIPE_CLI_WEBHOOK_SECRET for local signature verification) ``` **Key decisions** - **Payments live in SvelteKit, not Hono** — the app already owns SSR + server actions; Hono stays a pure data layer. Stripe secret never reaches the client. - **`fams.active`** is the single app-level gate: webhooks (subscription lifecycle) and the pause toggle both write it. It disables interactions + payments when `false`. - **Embedded Checkout** (in-page, no redirect) via `createEmbeddedCheckoutPage` — needs a same-origin `return_url`; subscriptions require a `customer` (created with `customer_creation: 'always'` + `customer_email` if the fam has none yet). - **Trial** is app-side: a code maps to `trial_period_days` on the subscription; the trial Stripe price is a `$0` plan. Real-world codes should move to a PB collection. - **Webhook secrets** — `STRIPE_CLI_WEBHOOK_SECRET` (dev) overrides `STRIPE_WEBHOOK_SECRET` (prod/dashboard); `verifyStripeEvent` picks the CLI one when set. Dev testing uses the Stripe CLI (`stripe listen --forward-to http://127.0.0.1:2080/account/webhook`) which forwards real signed events; a real checkout carries the `famId` and drives the DB write end-to-end. ### 5.4 UI reactivity - Svelte `$state` / `$derived` / `$effect`. - PB SDK `.subscribe()` for realtime multi-user sync (public reads → both roles). - `famStore.applyRecord()` for instant optimistic feedback. - **Rule:** if data should update via SSE, use `$derived` (not `$state` — frozen snapshot). Only high-frequency member actions use `$state` + optimistic `applyRecord`. --- ## 6. Project Structure ``` / (root) /shared/pb/schema.ts SCHEMA_PLAN — source of truth for base collections /frontend SvelteKit app (:2080) /src/env.ts declareEnvVars — client/server env /src/lib/server pb-admin, migrate.ts, services, hono.ts (sessionHeaders) /src/lib/client api.ts (memberApi), stores (famStore) /src/lib/components UI components (re-exported from index.ts) /src/routes SvelteKit file-based routing (incl /account, /subscriptions) /proxy Hono proxy (:3456) /docker Dockerfile (prod multi-stage + nginx), Dockerfile.dev (PB) /config.ts dev/build-time shared config (ports) /MEMORY.md decisions log AGENTS.md AI reference ARCHITECTURE.md This document ``` --- ## 7. Environment Variables Env vars declared in `frontend/src/env.ts` via `defineEnvVars` (`@sveltejs/kit/hooks`). Only `{ public: true }` are exposed client-side via `$app/env/public`; server-only via `$app/env/private`. - `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT` - `PB_EMAIL`, `PB_PASSWORD` (PB superuser, server-only) - `DEBUG_RECORD_ID` - `STRIPE_SECRET_KEY` (server-only) - `DONATION_MODAL_INTERVAL` - `SERVER_IP` (public, dev) Values come from root `.env` (symlinked at `frontend/.env -> ../.env`). `.env.example` is the committed template. Ports also tracked in root `config.ts`. --- ## 8. Key Conventions - **`famId` on every query** — PB auth rules enforce `famId = @request.auth.famId`; superuser bypasses. - **Member → Proxy (server/browser):** `memberApi.*` in `$lib/client/api.ts`; `BASE_URL` resolves to Hono port on server, empty in browser (Vite proxies `/api/*`). - **Admin → Proxy:** `hono.admin.*` in `$lib/server/hono.ts` — `sessionHeaders(event)` (server-only, requires `RequestEvent`). - **Child passwords derived** — `MEMBER_SECRET + famSlug + username`; join gate is a transient OTP. Never log raw tokens/secrets. - **`$page`** — from `$app/state` (not `$app/stores`); no `$` prefix. - **Dates** — user-facing via `formatDDMMYY()` (compact `040826`); human-readable due dates use `formatShortDate()`. Never render raw `YYYY-MM-DD`. - **UI shell** — `[fam]/+layout.svelte` (Sidebar, TopNav, Footer); `ViewHeader` + `CardGrid`/`Card` micro-layout; `Button` for CTAs; icons as SVG strings in `lib/components/icons.ts`. - **Reactivity** — never `$effect` to sync local state from famStore; use `$derived` for SSE-reactive data; `$state` + `applyRecord` only for optimistic member toggles. - **Dev servers** — never start your own; reuse running proxy (`192.168.1.225:3456`) + frontend (`localhost:2080`). --- ## 9. Open / Deferred - **WhatsApp notifications** — `NotificationService` plugin for the weekly CRON handler. Deferred. - **Stripe payments** — flow not yet implemented. Only `fams.stripeCustomerId` + `settings.webhookUrl` exist. Building in SvelteKit `/account` + `/account/webhook`. Trial via codes (app-side validation + `trial_period_days`) — TBD. - **Weekly CRON** (`/api/weekly-cron`, Coolify) — not implemented; settlement is manual via `complete-week`/`simulateEow`.