294 lines
20 KiB
Markdown
294 lines
20 KiB
Markdown
# 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 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` (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`)
|
|
- `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes)
|
|
- `platform` — label (`global` singleton), flags (json) — platform feature flags; public read (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggled from the `/admin` Platform Flags card (`debug` gates dev-only CTAs like settings "Revoke code"). Replaces the deprecated per-fam `fams.featureFlags`.
|
|
- `fams` — name, slug, stripeCustomerId, paymentMode (`none`|`code`|`sub`|`canceled`), active, accessCodeId, accessCodeEnteredAt
|
|
- `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 + the superuser-only `otp`/`accesscodes` and public-read `platform` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensureAccessCodes`/`ensurePlatform`); `ensureFamFields()` hardens existing installs with newer `fams` fields. 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 (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 seeded `pb_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-week` action or `simulateEow` preview 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.active`** gate the platform (see 5.3.2). Webhooks write `paymentMode`; `ensureFamAccess()` recomputes and persists `active` on every `[fam]` layout load.
|
|
- **Embedded Checkout** (in-page, no redirect) via `createEmbeddedCheckoutPage` with `ui_mode: 'embedded_page'` (`'embedded'` is deprecated) — needs a same-origin `return_url`. No `customer_creation` (subscription mode only; Stripe auto-creates the customer from `customer_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) overrides `STRIPE_WEBHOOK_SECRET` (prod/dashboard); `verifyStripeEvent` picks the CLI one when set. Dev testing uses the Stripe CLI (`pnpm stripe:listen`) which forwards real signed events; a real checkout carries the `famId` and 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.famAccess` from `[fam]/+layout.server.ts`.
|
|
- Disabled UX: layout blurs page content behind an overlay card + admin TopNav announcement (`/settings` is exempt so admins can apply a code / manage billing); member kanban renders empty locked columns and `toggle()` early-returns (frontend-only by decision).
|
|
- Entry points: optional code field at signup, Access card in settings (`?/applyCode`). Webhooks flip `paymentMode` to `sub`/`canceled`.
|
|
- Debug revoke: with the platform `debug` flag ON, settings shows a "Revoke code" CTA (`?/revokeCode`) that clears the applied code (back to `none`/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` + 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 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_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.
|
|
- **Server data access:** `servicesFor(event)` / `createServices(pb)` in `$lib/server/services/`; superuser ops via `pbAdmin` facade.
|
|
- **Browser data access:** same-origin fetch to `/api/*` SvelteKit endpoints; httpOnly `pb_token` cookie 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()` (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** — implemented in SvelteKit (`/pricing` public picker + inline signup checkout, settings Billing group with billing portal, `/api/webhooks/stripe` → `stripe-events.ts`; `?checkout=return` lands on the fam dashboard with a welcome notice). Remaining: platform-admin UI for managing `accesscodes`, prod webhook secret wiring, optional Stripe-level pause (see TODO.md).
|
|
- **Weekly CRON** (`/api/weekly-cron`, Coolify) — not implemented; settlement is manual via `complete-week`/`simulateEow`. |