Files
famdone/ARCHITECTURE.md
T
2026-08-20 07:35:31 +01:00

17 KiB

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 <pb_token>).
  • Admin CRUD: Form actions / hono.admin.* → Hono proxy → PB (admin JWT via sessionHeaders).
  • Member updates: Browser → Hono proxy → PB (Bearer <pb_token>).
  • 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.