20 KiB
FamDone 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 services + Stripe server routes (/api/*) |
Coolify Docker (nginx) | :2080 internal, :3001 external |
| PocketBase | DB, auth, realtime, storage, Admin UI | Coolify service (pb.chores.app.com) | :8090 |
| Stripe | subscriptions (SvelteKit server routes) | — | — |
Deployment Topology
chores.app.com ────┬──► nginx (:3001)
│ ├── /* ──► SvelteKit (:2080)
│ └── /api/* ──► SvelteKit (:2080)
│
pb.chores.app.com ──► PocketBase (:8090)
│ Admin UI at /_
│ Volume: /pb_data (persistence + backups)
│
stripe.com ─────────► SvelteKit /api/webhooks/stripe
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(roleparent). Login via email/password → httpOnlypb_tokencookie{ id, name, username, role: "parent", famId, color }. - Members are
users(rolechild); PBusername={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 inotp, thenauthWithPassword. Same httpOnlypb_tokencookie. Nomemberscollection. - Superuser used only server-side by
pb-admin.tsfor cross-family queries (/admin) and OTP/signup writes. - Layout
[fam]/+layout.server.tsderivesisParent/rolecentrally from the session.pb_tokenis httpOnly, so the browser PB SDK is seeded frompage.data.pbTokenviainitPb(token)inonMount.
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 onusers.color)accesscodes— value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes)platform— label (globalsingleton), flags (json) — platform feature flags; public read (empty list/view rules), superuser-only writes. Loaded on every page via root+layout.server.tsaspage.data.platformFlags; toggled from the/adminPlatform Flags card (debuggates dev-only CTAs like settings "Revoke code"). Replaces the deprecated per-famfams.featureFlags.fams— name, slug, stripeCustomerId, paymentMode (none|code|sub|canceled), active, accessCodeId, accessCodeEnteredAtchore_templates— famId, name, defaultValue, defaultFrequencyassigned_chores— famId, userId, templateId, frequency, valuecompletions— famId, userId, assignedChoreId, dateweekly_history— famId, userId, weekStart, pointsEarned, moneyEarnedrewards— famId, userId, source, label, value, claimed, claimedAt, claimable, settleDatemonthly_bonuses— famId, month, prizeType, prizeValue, winnerUserIdsettings— 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.tsbootstraps a fresh/wiped PB (idempotent). The nativeusersauth fields/rules + the superuser-onlyotp/accesscodesand public-readplatformcollections are applied inmigrate.ts(ensureUsers/ensureOtp/ensureAccessCodes/ensurePlatform);ensureFamFields()hardens existing installs with newerfamsfields. Data is disposable — schema change = updateSCHEMA_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 (wizard: fam → child → code → plan)
/{fam}/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
/settings (Billing group) Subscription status, change plan, open billing portal
/pricing 3-tier public plan page (trial | monthly | yearly), entry via settings or logged-out
/api/webhooks/stripe Stripe webhook handler (server route)
/api/* SvelteKit API endpoints (data layer; CRON not implemented)
5. Data Flow
5.1 Reads (both roles)
famStore.init()fetches all collections via PB SDK, authenticated via the seededpb_token..subscribe()works for both roles.- TopNav season pills read from
famStore.seasons(reactive).
5.2 Writes
- Chore toggle: Browser → SvelteKit
/api/completions/toggle→ PB (session cookie auth). - Admin CRUD: Form actions /
/api/admin/*endpoints → PB via services; PB collection rules are the security boundary. - Member updates: Browser → SvelteKit
/api/*routes → PB. - Reward creation: after completion toggle, service layer creates reward if threshold met.
- Weekly settlement: NOT via CRON — manual
complete-weekaction orsimulateEowpreview in settings./api/weekly-cron(Coolify) not implemented. - Stripe: SvelteKit server routes
/pricing+ settings Billing actions +/api/webhooks/stripe. - WhatsApp: not implemented.
5.3 Stripe Subscription (embedded Checkout)
PUBLIC PRICING SIGNUP WIZARD AFTER
───────────── ───────────── ─────
/pricing ── logged out ──► /signup?plan=X
└─ logged in ──► embedded checkout (existing behavior)
1. fam create family + parent
2. child add child / skip
3. code "Have an access code?"
├─ apply valid ──► 5. done (fam active)
└─ skip ─────────► 4. plan
4. plan PricingPlans component
├─ ?plan=X pre-highlights that tier
├─ pick tier ──► embedded checkout mounts INLINE
└─ trial tier hidden (codes live at step 3)
5. done "Go to dashboard"
webhook sets paymentMode=sub → overlay lifts
Webhook events (/api/webhooks/stripe) update fams.stripeCustomerId, fams.active, fams.paymentMode from subscription lifecycle.
5.3.1 Payments architecture
┌────────────────────────────────────────── SVELTEKIT APP ──────────────────────────────────────────┐
│ │
│ Browser │
│ ┌──────────────────────────────┐ POST ?/choose ┌─────────────────────────────────────────┐ │
│ │ /pricing (+page.svelte) │ ───────────────────► │ pricing/+page.server.ts (action) │ │
│ │ • PricingPlans component │ │ • logged-out: redirect /signup?plan=X │ │
│ │ • createEmbeddedCheckoutPage │ ◄─── clientSecret ─── │ • logged-in: createEmbeddedCheckout... │ │
│ │ • 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_page) │ │
│ └──────────────────────────────┘ └──────────────┬──────────────┘ │
│ │ webhook events │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ /api/webhooks/stripe (+server.ts) │ │
│ │ • verify stripe-signature (CLI secret in dev, dashboard in prod) │ │
│ │ • handleStripeEvent() → stripe-events.ts │ │
│ │ └ checkout.session.completed → fams.stripeCustomerId + active + paymentMode='sub' │ │
│ │ └ customer.subscription.* → fams.active + paymentMode (sync by customer id) │ │
│ │ └ customer.subscription.deleted → active=false + paymentMode='canceled' │ │
│ └──────────────────────────────────────────────────────┬─────────────────────────────────────┘ │
│ │ pbAdmin (superuser) │
└─────────────────────────────────────────────────────────┼─────────────────────────────────────────┘
▼
┌────────────────────┐
│ POCKETBASE │
│ fams.stripeCustomerId │
│ fams.active (bool) │
│ fams.paymentMode │
└────────────────────┘
SIGNUP WIZARD (inline checkout at step 4):
/signup?plan=X
1. fam create family + parent (no code field)
2. child add child / skip
3. code "Have an access code?" → apply or skip
4. plan PricingPlans component (hideTrial), selecting a plan
→ ?/choose action → createEmbeddedCheckoutSession → mount embedded inline
5. done "Go to dashboard" — webhook flips paymentMode=sub, overlay lifts
Management:
Settings → Billing group (+page.server.ts ?/billingPortal)
• createBillingPortalSession(customerId) → Stripe Customer Portal
(update card, cancel / reactivate subscription; returns to /{fam}?checkout=return)
Gating derives from fams.paymentMode alone (none = gated). No local pause flag.
Dev-only:
pnpm stripe:listen (root script)
= stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed
--forward-to http://127.0.0.1:2080/api/webhooks/stripe
(sets STRIPE_CLI_WEBHOOK_SECRET for local signature verification)
Embedded Checkout needs a secure context (HTTPS or localhost). Over Tailscale/LAN HTTP the
checkout iframe hangs silently — port-forward instead: ssh -L 2080:localhost:2080
Key decisions
- Payments live in SvelteKit server routes — the app owns SSR + server actions end-to-end. Stripe secret never reaches the client.
fams.paymentMode+fams.activegate the platform (see 5.3.2). Webhooks writepaymentMode;ensureFamAccess()recomputes and persistsactiveon every[fam]layout load.- Embedded Checkout (in-page, no redirect) via
createEmbeddedCheckoutPagewithui_mode: 'embedded_page'('embedded'is deprecated) — needs a same-originreturn_url. Nocustomer_creation(subscription mode only; Stripe auto-creates the customer fromcustomer_email). - Access codes are a real PB collection (
accesscodes, superuser-only) — entered at signup or via settings; replaces the earlier app-side trial-code idea. - Webhook secrets —
STRIPE_CLI_WEBHOOK_SECRET(dev) overridesSTRIPE_WEBHOOK_SECRET(prod/dashboard);verifyStripeEventpicks the CLI one when set. Dev testing uses the Stripe CLI (pnpm stripe:listen) which forwards real signed events; a real checkout carries thefamIdand drives the DB write end-to-end.
5.3.2 Access gating (fams.paymentMode / accesscodes)
computeFamAccess(fam, code?) → { disabled, reason } lib/server/access.ts
none → disabled ("no_access") fresh signup, no code/sub
code → valid while accesscodes.active && !expired && !durationExhausted
(duration/expiry 0 = continuous/never; months measured from
fam.accessCodeEnteredAt / code.createdAt)
sub → follows webhook-maintained fams.active
canceled → disabled ("canceled")
ensureFamAccess(famId): reads fam+code, persists drifted fams.active, returns {fam, access}
applyAccessCode(famId, value): validates + sets paymentMode='code' + entry stamp
- Exposed to all fam pages as
data.famAccessfrom[fam]/+layout.server.ts. - Disabled UX: layout blurs page content behind an overlay card + admin TopNav announcement (
/settingsis exempt so admins can apply a code / manage billing); member kanban renders empty locked columns andtoggle()early-returns (frontend-only by decision). - Entry points: optional code field at signup, Access card in settings (
?/applyCode). Webhooks flippaymentModetosub/canceled. - Debug revoke: with the platform
debugflag ON, settings shows a "Revoke code" CTA (?/revokeCode) that clears the applied code (back tonone/gated). Server-side flag check is the boundary. - Seeded dev code:
dev123(developer, duration 0, expiry 0).
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+ optimisticapplyRecord.
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 pocketbase.ts (pbAdmin), migrate.ts, access.ts, platform.ts, services/
/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 /pricing, /signup wizard, /api/webhooks/stripe)
/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_PORTPB_EMAIL,PB_PASSWORD(PB superuser, server-only)DEBUG_RECORD_IDSTRIPE_SECRET_KEY(server-only)DONATION_MODAL_INTERVALSERVER_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
famIdon every query — PB auth rules enforcefamId = @request.auth.famId; superuser bypasses.- Server data access:
servicesFor(event)/createServices(pb)in$lib/server/services/; superuser ops viapbAdminfacade. - Browser data access: same-origin fetch to
/api/*SvelteKit endpoints; httpOnlypb_tokencookie is the auth. - 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()(compact040826); human-readable due dates useformatShortDate(). Never render rawYYYY-MM-DD. - UI shell —
[fam]/+layout.svelte(Sidebar, TopNav, Footer);ViewHeader+CardGrid/Cardmicro-layout;Buttonfor CTAs; icons as SVG strings inlib/components/icons.ts. - Reactivity — never
$effectto sync local state from famStore; use$derivedfor SSE-reactive data;$state+applyRecordonly 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 —
NotificationServiceplugin for the weekly CRON handler. Deferred. - Stripe payments — implemented in SvelteKit (
/pricingpublic picker + inline signup checkout, settings Billing group with billing portal,/api/webhooks/stripe→stripe-events.ts;?checkout=returnlands on the fam dashboard with a welcome notice). Remaining: platform-admin UI for managingaccesscodes, prod webhook secret wiring, optional Stripe-level pause (see TODO.md). - Weekly CRON (
/api/weekly-cron, Coolify) — not implemented; settlement is manual viacomplete-week/simulateEow.