268 lines
17 KiB
Markdown
268 lines
17 KiB
Markdown
## Project Configuration
|
|
|
|
- **Language**: TypeScript
|
|
- **Package Manager**: pnpm
|
|
- **Add-ons**: prettier, tailwindcss, sveltekit-adapter, experimental
|
|
|
|
---
|
|
|
|
# FamDone v2 — AI Agent Reference
|
|
|
|
## Stack
|
|
|
|
- SvelteKit monolith (SSR frontend + all services, internal :2080; nginx in prod container :3001). The Hono proxy was deleted — everything lives in SvelteKit server routes/services.
|
|
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
|
|
- Stripe payments (subscriptions) — implemented in **SvelteKit server routes** (public `/pricing` + inline signup checkout via `PricingPlans`, billing portal from settings Billing section, `/api/webhooks/stripe`). Dev webhook listener: `pnpm stripe:listen` (root script). Embedded Checkout needs a secure context (HTTPS/localhost) — over Tailscale/LAN HTTP use `ssh -L 2080:localhost:2080`.
|
|
- Access gating — `fams.paymentMode` (`none|code|sub|canceled`) + `fams.active`; codes in superuser-only `accesscodes` (seeded `dev123`). Core logic in `frontend/src/lib/server/access.ts`, exposed as `data.famAccess` from `[fam]/+layout.server.ts`; disabled fams get a blurred overlay + locked member kanban (frontend-only); `/settings` stays unlocked so admins can apply a code.
|
|
- Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`)
|
|
- Deployment: Coolify, Cloudflare DNS
|
|
|
|
## Auth
|
|
|
|
| Role | Auth | Session | Record in |
|
|
| -------------- | ----------------------------------------------- | ------------------------------------------------ | ----------------------- |
|
|
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
|
|
| Member (child) | Invite OTP + server-derived password | Shared-device: `pb_token_<userId>` + `pb_active` | `users` (role `child`) |
|
|
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
|
|
|
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
|
|
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. **Shared-computer sessions:** children keep ONE httpOnly cookie per account (`pb_token_<userId>`, set at join) + a `pb_active` cookie naming the current session; a 3-digit PIN _selects_ among those device sessions (see `shared-device.md`) — it is NOT a login and mints nothing on a fresh device. Parents stay on the single `pb_token`. Resolution order in hooks: `pb_active` → `pb_token`. There is **no `members` collection**.
|
|
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
|
|
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
|
|
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
|
|
|
|
## PB Collections (all scoped by `famId`; child/member = `users` row)
|
|
|
|
- `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 lives on `users.color`)
|
|
- `pins` — famId, userId, pin (superuser-only shared-device PINs, plaintext so parents can read them out; all access via server endpoints)
|
|
- `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`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code").
|
|
- `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` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp`/`pins` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensurePins`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
|
|
|
## Routes
|
|
|
|
```
|
|
/ Landing (SaaS marketing)
|
|
/admin Platform super-admin stats dashboard (and any donations)
|
|
/login · /logout Parent email/password login / logout
|
|
/signup Parent + family signup (wizard: fam → child → code → plan)
|
|
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
|
/{fam}/switch Shared-device profile picker (standalone landing when child sessions exist but none active)
|
|
/{fam} Fam dashboard
|
|
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
|
|
/{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 (parent→users, member→users)
|
|
/{fam}/{username}/settings Family admin settings (parent only) — includes Stripe connect/manage + pause
|
|
/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)
|
|
```
|
|
|
|
## Data Flow
|
|
|
|
### Reads (both roles)
|
|
|
|
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via the `pb_token` cookie / seeded `initPb(token)`).
|
|
- **Child (member):** `famStore.init()` fetches all collections via PB SDK — authenticated via their `pb_token` (role `child`). Family-scoped collections have public `listRule` / `viewRule`, so reads work regardless; `.subscribe()` works for both roles.
|
|
- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
|
|
|
|
### Writes
|
|
|
|
- **Chore toggle:** Browser → SvelteKit `/api/completions/toggle` → PB (session cookie auth)
|
|
- **Admin CRUD:** Form actions / `/api/admin/*` endpoints → PB via services (`servicesFor(event)`); 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) is not implemented.
|
|
- **Stripe:** implemented in SvelteKit server routes — `/pricing` (public plan picker; logged-in users checkout inline) + settings Billing section (billing portal) + `/api/webhooks/stripe`. **WhatsApp:** not implemented.
|
|
|
|
### UI reactivity
|
|
|
|
- Svelte `$state` / `$derived` / `$effect`
|
|
- PB SDK `.subscribe()` for realtime multi-user sync (public reads → works for both roles)
|
|
- `famStore.applyRecord()` for instant optimistic UI feedback from form actions
|
|
|
|
## Env Vars (SvelteKit 3.0.0-next.4)
|
|
|
|
Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`:
|
|
|
|
```ts
|
|
import { defineEnvVars } from "@sveltejs/kit/hooks";
|
|
export const variables = defineEnvVars({
|
|
DEBUG_RECORD_ID: {},
|
|
SERVER_IP: { public: true },
|
|
PB_PORT: { public: true },
|
|
});
|
|
```
|
|
|
|
Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must:
|
|
|
|
1. Add it to `.env` with `PUBLIC_` prefix
|
|
2. Add it to `frontend/src/env.ts` with `{public: true}`
|
|
|
|
The actual values come from `.env` (symlinked from project root at `frontend/.env -> ../.env`). Server-only env vars (no `{public}` flag) are available via `$app/env/private` but only in server modules.
|
|
|
|
## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore)
|
|
|
|
**Do NOT do this:**
|
|
|
|
```svelte
|
|
<!-- ❌ WRONG: $effect syncing local state from famStore fights Svelte reactivity -->
|
|
let items = $state(data.items);
|
|
$effect(() => {
|
|
if (data.items?.length) items = data.items;
|
|
if (famStore.initialized && famStore.items.length) items = famStore.items;
|
|
});
|
|
```
|
|
|
|
**Do this instead:**
|
|
|
|
```svelte
|
|
<!-- ✅ RIGHT: Initialize from famStore, mutate via applyRecord in callbacks -->
|
|
let items = $state(famStore.initialized ? famStore.items : (data.items || []));
|
|
```
|
|
|
|
### ⚠️ CRITICAL: `$state` vs `$derived` for famStore collections
|
|
|
|
**When a page reads data that should update via SSE (realtime from other users/actions), you MUST use `$derived` — NOT `$state`.**
|
|
|
|
This is the most commonly missed rule. Agents repeatedly initialize collections from `famStore` with `$state`, which captures a **frozen snapshot** at init time. When PocketBase pushes a record via SSE → `famStore.applyRecord()`, the `$state` variable never reacts — the UI stays stale until a page refresh.
|
|
|
|
**Rule: if the data should update without a refresh, use `$derived`.**
|
|
|
|
```svelte
|
|
// ❌ WRONG — frozen snapshot, won't react to SSE updates
|
|
let configs = $state(famStore.initialized ? famStore.bonusConfigs : data.configs);
|
|
|
|
// ✅ RIGHT — reactive, updates when famStore changes via SSE
|
|
let configs = $derived(
|
|
famStore.initialized ? famStore.bonusConfigs : (data.configs || [])
|
|
);
|
|
```
|
|
|
|
**The only exception:** high-frequency member actions (like chore toggles) that need **optimistic UI** before the server responds. Those use `$state` + `famStore.applyRecord()` for instant feedback, then reconcile on the server response. See `applyRecord` pattern below.
|
|
|
|
**All other pages (admin CRUD, bonuses, rewards, settings, etc.) must use `$derived`** so that SSE updates flow through `famStore` → `$derived` → UI automatically.
|
|
|
|
## Update Patterns
|
|
|
|
Two patterns based on who's acting:
|
|
|
|
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
|
|
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ---------------------------------- |
|
|
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
|
|
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie |
|
|
|
|
**Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
|
|
|
|
```svelte
|
|
let completions = $state(data.completions)
|
|
async function toggle(chore) {
|
|
// optimistic update
|
|
completions = [...completions, { id: 'optimistic-...', ... }]
|
|
try {
|
|
await memberApi.toggleCompletion(token, famId, chore.id, date)
|
|
// reconcile — remove optimistic, keep server truth
|
|
} catch { /* revert */ }
|
|
}
|
|
```
|
|
|
|
**Admin form actions** — no optimistic `applyRecord` needed in `use:enhance` callbacks. The form action is a server round trip, and PB SSE pushes the change back through `famStore.handleRealtime()` within milliseconds. The famStore + SSE subscription is the single source of truth for cross-user sync. Do NOT add `$effect` watchers to bridge the gap between form actions and reactive state.
|
|
|
|
## UI Component Conventions
|
|
|
|
All admin and member pages use the following pattern:
|
|
|
|
```svelte
|
|
<ViewHeader title="..." subtitle="..." tabs={...} weeknav={...} sort={...} />
|
|
<CardGrid>
|
|
<Card {cols} title="..." accent="...">
|
|
<!-- card content → micro-layout per page -->
|
|
</Card>
|
|
</CardGrid>
|
|
```
|
|
|
|
- **Layout shell** lives in `[fam]/+layout.svelte` — Sidebar, TopNav, Footer. All fam-scoped routes inherit it.
|
|
- **Sidebar** role-aware: shows admin CTAs on `/admin/*`, member CTAs on `/[username]/*`.
|
|
- **TopNav** has `announcement` (center slot) and `actions` (right slot).
|
|
- **`Card` `cols` prop**: `1 | 2 | 3` — spans that many columns in the 3-column `CardGrid`.
|
|
- **`Button`** for all CTAs: `<Button variant="primary|secondary|ghost|danger" size="sm|md|lg">`.
|
|
- **`Accordion`** for expand/collapse sections (settings, logs).
|
|
- **Icons**: defined as SVG strings in `lib/components/icons.ts`. No icon library dependency.
|
|
- **Components** live in `frontend/src/lib/components/` and are re-exported from `index.ts`.
|
|
|
|
## Conventions
|
|
|
|
- Every collection query includes `famId = @request.auth.famId` filter
|
|
- Super admin bypasses famId filter (access via PB admin API)
|
|
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets.
|
|
- **Server data access**: `servicesFor(event)` / `createServices(pb)` in `$lib/server/services/`; superuser ops via `pbAdmin` facade (`$lib/server/pocketbase.ts`)
|
|
- **Browser data access**: fetch to same-origin `/api/*` SvelteKit endpoints; httpOnly `pb_token` cookie is the auth
|
|
- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix
|
|
- **Dates**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Use the shared `formatDDMMYY()` helper in `frontend/src/lib/format.ts`. Never render raw `YYYY-MM-DD` to users. Exception: single human-readable dates like todo **due dates** should use `formatShortDate()` (also in `format.ts`, renders `5 Aug` / `5 Aug 26`) — the compact DDMMYY code is ambiguous and bad UI for those.
|
|
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars
|
|
- `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template
|
|
- Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase)
|
|
- Nginx routes in prod: `/*` → SvelteKit (`:2080`), `/pb/*` → PocketBase
|
|
- Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved)
|
|
- **Dev servers: NEVER start your own.** Always reuse the running dev servers — proxy `192.168.1.225:3456` (tsx watch, reloads on edit), frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev` / `tsx watch` / extra vite instances. Only restart when the user explicitly asks.
|
|
- Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`
|
|
- Seed via JSON dump (portable for dev)
|
|
- Monorepo: SvelteKit in `frontend/` (+ root `shared/`), single app Dockerfile + PB Dockerfile.dev
|
|
- Decisions tracked in `MEMORY.md`
|
|
|
|
## Build Phases (must validate each before next)
|
|
|
|
### Phase 1 — Infrastructure
|
|
|
|
~~1.1 Scaffold SvelteKit + Hono monorepo~~
|
|
~~1.2 Write Dockerfiles (frontend + backend, correct port mapping)~~
|
|
~~1.3 Sort out vars (`.env` + `.env.example`)~~
|
|
~~1.5 Validate Hono `/api/*` reachable, env vars injected~~
|
|
~~1.6 Validate SvelteKit↔PB connectivity (admin API read/write)~~
|
|
|
|
### Phase 2 — Backend Core
|
|
|
|
2.1 Create PB collections via schema/migration
|
|
2.2 Super admin seed + fam signup flow
|
|
2.3 Fam admin login (email/pass → 24hr JWT)
|
|
2.4 Invite code generation + member join flow
|
|
2.5 Device token auth + route guards
|
|
2.6 Svelte reactive state management (no PB SSE)
|
|
|
|
### Phase 3 — Backend Data Streams
|
|
|
|
3.1 Chore template CRUD + assignment grid (admin)
|
|
3.2 Completion toggle (member → PB direct)
|
|
3.3 Weekly progress + history computation
|
|
3.4 Reward auto-creation on threshold
|
|
3.5 Reward claim flow + admin CRUD
|
|
3.6 Monthly bonus evaluation
|
|
3.7 CRON handler (Coolify → Hono)
|
|
3.8 Stripe checkout + webhook (SvelteKit server routes, not Hono)
|
|
3.9 Notification interface (WhatsApp deferred)
|
|
|
|
### Phase 4 — Frontend App
|
|
|
|
4.1 Member kanban (3-column, live SSE updates)
|
|
4.2 Admin dashboard (weekly overview, chart)
|
|
4.3 Admin panel (members, chores, rewards, settings)
|
|
4.4 Landing page (SaaS marketing)
|
|
4.5 Super admin stats dashboard
|
|
4.6 Donation modal
|
|
4.7 QR invite code
|
|
4.8 Polish (loading, empty, error states, responsive)
|
|
|
|
### Phase 5 deployment of production
|