migrate auth v2 code

This commit is contained in:
JCEEE
2026-08-16 10:11:39 +01:00
parent 3dd94b8a7c
commit c73ced7894
45 changed files with 1965 additions and 1519 deletions
+3
View File
@@ -6,6 +6,9 @@
# PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123. # PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123.
PB_EMAIL= PB_EMAIL=
PB_PASSWORD= PB_PASSWORD=
# Server-only secret used to derive a child member's PB password from
# (famSlug + username). Never expose client-side. OTP is the access gate.
MEMBER_SECRET=
# Public: the dev machine's IP where PB + the dev proxy run. Change this when # Public: the dev machine's IP where PB + the dev proxy run. Change this when
# your remote IP changes — the browser (pocketbase.ts) and pb-admin read it. # your remote IP changes — the browser (pocketbase.ts) and pb-admin read it.
# Prod ignores this (uses /pb via nginx). Default: 192.168.1.225. # Prod ignores this (uses /pb via nginx). Default: 192.168.1.225.
+43 -41
View File
@@ -12,69 +12,71 @@
- SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001) - SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001)
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090) - PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
- Stripe one-time donations - Stripe one-time donations — **not implemented** (only `settings.webhookUrl` exists)
- Coolify CRON → `GET /api/weekly-cron` - Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`)
- Deployment: Coolify, Cloudflare DNS - Deployment: Coolify, Cloudflare DNS
## Auth ## Auth
| Role | Auth | Session | Record in | | Role | Auth | Session | Record in |
| -------------- | -------------------------- | -------------------------- | ------------ | | -------------- | --------------------------------------------- | -------------------------- | ------------------------- |
| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | | Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
| Member (child) | Invite code + device token | `device_token` cookie only | `members` | | Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) |
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
- **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`. - **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) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie. - **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 `user_configs`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. 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). Not an app role. - **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 cookie — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. - 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`) ## PB Collections (all scoped by `famId`; child/member = `users` row)
- `fams` — name, slug, inviteCode, stripeCustomerId, featureFlags - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
- `members` — famId, name, color, deviceToken(hashed), deviceTokenHint - `user_configs` — famId, userId, otp, colour, updatedAt (OTP gate for child join)
- `fams` — name, slug, stripeCustomerId, featureFlags
- `chore_templates` — famId, name, defaultValue, defaultFrequency - `chore_templates` — famId, name, defaultValue, defaultFrequency
- `assigned_chores` — famId, memberId, templateId, frequency, value - `assigned_chores` — famId, userId, templateId, frequency, value
- `completions` — famId, memberId, assignedChoreId, date - `completions` — famId, userId, assignedChoreId, date
- `weekly_history` — famId, memberId, weekStart, pointsEarned, moneyEarned - `weekly_history` — famId, userId, weekStart, pointsEarned, moneyEarned
- `rewards` — famId, memberId, source, label, value, claimed, claimedAt - `rewards` — famId, userId, source, label, value, claimed, claimedAt, claimable, settleDate
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerMemberId - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
## Routes ## Routes
``` ```
/ Landing (SaaS marketing) / Landing (SaaS marketing)
/admin Admin panel - statistic dashboard, and any donations made /admin Platform super-admin stats dashboard (and any donations)
/join/:code Member invite code /login · /logout Parent email/password login / logout
/join/:code/:member Member invite with pre-selected member /signup Parent + family signup
/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code=
/{fam} Fam dashboard /{fam} Fam dashboard
/{fam}/admin Admin panel /{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
/{fam}/:username Member kanban & admin dashboard (role determined by session) /{fam}/{username}/chores Chore templates & assignment grid
/{fam}/:username/preferences User preferences (admin→fam_admins, member→members) /{fam}/{username}/ledger Rewards / chores / todos ledger
/{fam}/:username/settings Family admin settings (session required) /{fam}/{username}/bonuses Bonus configs & evaluation
/{fam}/:username/chores Chore templates & assignment grid /{fam}/{username}/preferences User preferences (parent→users, member→users)
/{fam}/:username/rewards Rewards overview /{fam}/{username}/settings Family admin settings (parent only)
/{fam}/:username/bonuses Bonus configs & evaluation /api/* Hono proxy (data layer; webhooks/CRON not implemented)
/api/* Hono proxy (webhooks, CRON)
``` ```
## Data Flow ## Data Flow
### Reads (both roles) ### Reads (both roles)
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie). - **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 — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections. - **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. - **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
### Writes (both roles go through Hono proxy) ### Writes
- **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT) - **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer <pb_token>`)
- **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`) - **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`)
- **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`) - **Member updates:** Browser → Hono proxy → PB (auth via `Bearer <pb_token>`)
- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met - **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met
- **Weekly CRON:** Coolify → `GET /api/weekly-cron` on Hono → Hono queries PB, computes summaries, upserts weekly_history - **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented.
- **Stripe donate:** Browser → Hono `/api/stripe/create-checkout` → Stripe → Hono webhook → update fam - **Stripe / WhatsApp:** not implemented — only the `settings.webhookUrl` field exists.
- **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface
### UI reactivity ### UI reactivity
@@ -150,8 +152,8 @@ Two patterns based on who's acting:
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | | Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- | | ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- |
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header | | 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 `session` cookie | | 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: **Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
@@ -195,7 +197,7 @@ All admin and member pages use the following pattern:
- Every collection query includes `famId = @request.auth.famId` filter - Every collection query includes `famId = @request.auth.famId` filter
- Super admin bypasses famId filter (access via PB admin API) - Super admin bypasses famId filter (access via PB admin API)
- `deviceToken` stored as SHA-256 hash; never log raw tokens - Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `user_configs`. No device tokens. Never log raw tokens/secrets.
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`) - **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server - **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono - **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
+23 -1
View File
@@ -47,7 +47,9 @@
- `/api/admin/signup` now creates a member record for the parent with `role: 'parent'` - `/api/admin/signup` now creates a member record for the parent with `role: 'parent'`
- `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info - `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info
- `/api/members/verify-token` returns `role` for the frontend - `/api/members/verify-token` returns `role` for the frontend
- `requireAdmin` middleware unchanged (still checks `fam_admins`) - `requireAdmin` middleware checks `users` (`role='parent' && id = userId`, scoped by `famId`)
- **Removed `fam_admins` collection (Aug 2026):** parent identity fully lives on the `users` record (`famId`, `role`, `name`, `color`, `email`). `requireAdmin`, `authorizeFamReq`, `resolveChatActor`, admin `/profile` get/patch, and the legacy proxy `/signup`/`/login` now read/write `users` instead. Signup no longer creates a `fam_admins` row; superadmin stats derive `parentEmail` from `users role='parent'`. Migrate step 34 drops the collection.
- **Removed `fams.inviteCode` (Aug 2026):** join flow is OTP-based (`user_configs.otp`), so the stored/displayed/regenerated invite code was never consumed. Removed `randomCode()` at signup, the `/regen-invite` endpoint + `hono.admin.regenInvite` action, the `inviteCode` type/field, and added migrate step 34 to drop the field.
- Frontend sidebar is role-aware: `isParent = session !== null` - Frontend sidebar is role-aware: `isParent = session !== null`
- **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc. - **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc.
@@ -221,3 +223,23 @@
- Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`. - Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`.
- **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create. - **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create.
- The 0.39 prod `messages` drift (missing `createdAt`, then `id` "Cannot be blank" after a raw field PATCH) came from schema mismatch during the bump. When migrating 0.25→0.39, reconcile the schema scripts against 0.39's field semantics (incl. system `id` `autogeneratePattern`) instead of patching collections by hand. - The 0.39 prod `messages` drift (missing `createdAt`, then `id` "Cannot be blank" after a raw field PATCH) came from schema mismatch during the bump. When migrating 0.25→0.39, reconcile the schema scripts against 0.39's field semantics (incl. system `id` `autogeneratePattern`) instead of patching collections by hand.
### 2026-08-15 — Members→users migration, OTP child login, cookie httpOnly, slugify
- **Migration (members → users, run live + verified):** deleted the `members` collection and repointed the 5 `memberId` relations (`assigned_chores`, `completions`, `rewards`, `weekly_history`, `bonus_configs`) → `users`. Added `users.name` + `users.color`, backfilled child name/color; backfilled parent `role` (`''` → `'parent'`). Scoped `users` rules: list/view = `famId = @request.auth.famId`, update/delete = `famId = @request.auth.famId && @request.auth.role = 'parent'`. Re-runs are idempotent.
- **PB relation quirk:** PB forbids changing a relation's target collection in place (`validation_field_relation_change`) — you must **drop the field and re-add it** targeting the new collection in two separate collection updates.
- **Child (member) auth:** children are `users` records with `role='child'`, PB `username = {famSlug}:{handle}` (composite, globally unique), `name` = raw display name. Server derives the PB password = `MEMBER_SECRET + famSlug + handle`; the join gate is a 20-min OTP in `user_configs`, then `authWithPassword`. Children now hold a `pb_token` cookie (previously a `device_token` cookie with no session). `SessionUser` gained `username` (set in `hooks.server.ts`, stores `handleOf(record.username)`); the kanban child redirect compares `session.username`, not `session.name`.
- **Cookie httpOnly:** `pb_token` is now `httpOnly:true`. The browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` (layout onMount) — **not** from `document.cookie` (the client can no longer read it). Logout is server-side only. Note: the JWT is still shipped to the client in SSR HTML via the `pbToken` prop.
- **Typecheck baselines:** frontend `svelte-check` = 20 pre-existing canary errors (chat `json(status)` ResponseInit, `$types` Action/SubmitFunction, qrcode decl, vite.config, RewardType/Frequency casts, implicitly-any); proxy `tsc --noEmit` = pre-existing record-typing + implicit-any errors. No new errors in edited files.
- **`crypto.randomUUID()` unavailable over plain HTTP** (non-secure context) → added `genClientId()` fallback (randomUUID if available, else `Date.now().toString(36)+random`) in `chat.svelte.ts`.
- **Shared slugify util:** `shared/slugify.ts` (`@shared/slugify` alias) — used by proxy signup + fam rename, frontend signup + settings `renameFam`, and `member-otp.createChild` (child username + password gen). Removed the 3 local duplicate `slugify` helpers.
- **Settings UI:** CardGrid/Card are now responsive (media queries + `--grid-cols`/`--card-cols`; Cards use `container-type: inline-size`). Members card split into two columns: add-child form | member list (space-between rows, larger 22px colour circle, "Preview" CTA → `/{famSlug}/{m.username}`, Remove). Family Name card gained an explainer + mono `/{fam.slug}` slug line.
- **Hono audit:** the proxy is the live data layer — admin CRUD/reads via `hono.admin.*` (kanban load, bonuses ~17 calls, ledger 6, preferences 2, fam dash 4, settings `complete-week`/`debug/generate-data`), member actions via `memberApi.*` (toggleCompletion/claimReward/payday) + direct `/api` fetches (chores assigned-chores, kanban `/api/members/me`). **Not implemented:** `/api/weekly-cron` CRON, Stripe (`create-checkout` + webhook), WhatsApp — weekly settlement is manual via `complete-week`/`simulateEow`. Chat endpoints exist in the proxy but the frontend talks to PB directly.
### 2026-08-15 — Signup: multi-step flow restored + family-creation bug fixed
- **Bug (blocker):** new family creation failed with PB `{"username":{"code":"validation_required","message":"Cannot be blank."}}` — the `users.create` in `/signup` omitted the auth `username` field (PB 0.39 requires it). Reproduced directly against PB: create WITHOUT `username` → `validation_required`; WITH `username` → succeeds.
- **Fix:** `signup/+page.server.ts` now includes `username` on the parent `users` create.
- **Username convention (composite + handle):** PB `users.username` is the composite `{famSlug}:{handle}` for BOTH parents and children — globally unique (PB auth-identity needs a single-column unique index) even though the URL segment is per-family. `handle(name)` = lowercase, strips all non-`[a-z0-9]` (`"Jakey Boy"` → `jakeyboy`); `slugify()` (hyphenated) is kept only for fam slugs. URL segment = `handleOf(username)` (part after the last `:`) → `/{famSlug}/{handle}`. `name` keeps the raw display name, read from DB via `authRefresh` (not plucked into the cookie). Parent's handle captured at signup step 1 (`yourName`) → `username = famUsername(famSlug, handle(yourName))`; parents authenticate email+password and land on the fam dashboard `/{famSlug}` (not username-routed). Children authenticate via OTP → `authWithPassword(famUsername(...), derivePassword(famSlug, handle))`. Redirects in `login/+page.server.ts`, `[fam]/[username]/+page.server.ts` (parent + child branches) and `preferences/+page.server.ts` use `session.username` (the handle). Member-list URLs in `settings`, `[fam]/+page.svelte`, `[fam]/[username]/+page.svelte` build `/{famSlug}/{handleOf(m.username)}`. `handle`/`handleOf`/`famUsername` live in `shared/slugify.ts` (`@shared/slugify`).
- **Restored intended multi-step signup** (from the guide, adapted to current OTP model): `/signup` steps — (1) `?/signup` familyName/yourName/email/password → create fam + parent (name=yourName, username=famUsername(famSlug, handle(yourName))) + settings, set `pb_token`; (2) `?/child` optional child → `issueAccess` returns `{ code, joinUrl }`; (3) show OTP join code + "Go to dashboard" link. Uses named actions + `use:enhance` (callback typed `any` to avoid the pre-existing canary `$types` SubmitFunction error).
- **Typecheck:** frontend `svelte-check` stays at 20 pre-existing errors (no new in edited files).
+78 -81
View File
@@ -7,31 +7,31 @@ still points at it, but no auth traffic flows through it.
``` ```
┌────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────┐
│ BROWSER (Svelte) │ │ BROWSER (Svelte) │
│ /login /signup forms · $app/forms · use:enhance │ │ /login /signup /join forms · $app/forms · enhance │
└──────────────────────────┬─────────────────────────────┘ └──────────────────────────┬─────────────────────────────┘
│ 1. form action POST /login │ 1. form action POST
│ /signup (returns result) │ /login · /signup · /join
▼ ▼
┌────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────┐
│ SVELTEKIT SERVER (Node) │ │ SVELTEKIT SERVER (Node) │
│ │ │ │
│ hooks.server.ts (runs once per request, first) │ │ hooks.server.ts (runs once per request, first) │
│ · read httpOnly cookie pb_session │ │ · read httpOnly cookie pb_token │
│ · createPbClient(token) → pb.authRefresh() │ │ · createPbClient(token) → pb.authRefresh() │
│ · → { id, username, role, famId } │ │ · → { id, name, username, role, famId, color } │
│ · event.locals.user / event.locals.pbToken │ │ · event.locals.user / event.locals.pbToken │
│ · route guards: public vs authed vs /admin │ │ · route guards: public vs authed vs admin-only │
│ │ │ │
│ +page.server.ts (actions / loads) │ │ +page.server.ts (actions / loads) │
│ login.ts / signup.ts / session.ts │ │ signup.ts / login.ts / member-otp.ts / session.ts │
│ · createPbClient / createSuperPbClient │ │ · createSuperClient (signup / OTP, superuser) │
│ (ABSOLUTE PB URL - server-to-server, no proxy) │ │ · createPbClient(token) (server-to-server) │
└──────────────────────────┬─────────────────────────────┘ └──────────────────────────┬─────────────────────────────┘
│ 2. PB API (REST) │ │ 2. PB API (REST) │
▼ ▼
┌────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────┐
│ POCKETBASE (100.103.22.104:8090) │ │ POCKETBASE (SERVER_IP:8090) │
│ collections: users (auth) · fams · members · ... │ │ collections: users (auth) · fams · ... │
│ │ │ │
│ PB is the SOURCE OF TRUTH: │ │ PB is the SOURCE OF TRUTH: │
│ · password hashing (bcrypt-style) │ │ · password hashing (bcrypt-style) │
@@ -43,96 +43,93 @@ still points at it, but no auth traffic flows through it.
Future (NOT auth): SvelteKit → /api proxy → Hono → email & other services Future (NOT auth): SvelteKit → /api proxy → Hono → email & other services
``` ```
## Roles (in the `users` collection)
| Role | Auth | Session cookie | Record in |
| -------- | --------------------------------------------- | ------------------------- | ---------- |
| Admin | PB email + password | `pb_token` (24hr JWT) | `users` (role `parent`) |
| Member | Invite OTP + server-derived password | `pb_token` (httpOnly) | `users` (role `child`) |
| Superuser| PB `_superusers` (server-side only, `pb-admin`) | — | — |
- **Admins (parents)** authenticate via email/password → PB JWT in an httpOnly `pb_token`
cookie. The session user is `{ id, name, username, role: 'parent', famId, color }`.
- **Members (children)** are `users` records with `role='child'`; their PB `username` is the composite `{famSlug}:{handle}` (globally unique auth identity) where `handle` is the whitespace-free lowercase form of their name, and `name` keeps the raw display name. Their PB password is
**derived** server-side as `MEMBER_SECRET + famSlug + username` (they never know or type it).
Access is gated by a 20-minute OTP in `user_configs`. On join they `authWithPassword` and get
the same httpOnly `pb_token` cookie. There is no separate `members` collection anymore.
- **Platform superuser** (`_superusers`) is used only server-side by `pb-admin.ts` for
cross-family / signup / OTP writes. Not an app role.
## Request lifecycle (authenticated) ## Request lifecycle (authenticated)
1. Browser sends request; sends cookie `pb_session` (httpOnly, sameSite=lax, secure in prod). 1. Browser sends request; sends cookie `pb_token` (httpOnly, sameSite=lax, secure in prod).
2. `hooks.server.ts` extracts the token. 2. `hooks.server.ts` extracts the token.
3. `createPbClient(token)` builds a PB client pre-authenticated as that user. 3. `createPbClient(token)` builds a PB client pre-authenticated as that user.
4. `pb.collection('users').authRefresh()`: 4. `pb.collection('users').authRefresh()`:
- validates the token (PB JWTs can't be checked offline), - validates the token (PB JWTs can't be checked offline),
- returns the fresh record → `event.locals.user = { id, username, role, famId }`, - returns the fresh record → `event.locals.user = { id, name, username, role, famId, color }`,
- returns a fresh token; if it changed, the cookie is rolled forward. - returns a fresh token; if it changed, the cookie is rolled forward.
5. Route guard runs (public / authed / admin-only). 5. Route guard runs (public / authed / admin-only).
6. `+page.server.ts` / `+server.ts` use `locals.user` for identity; use the token-backed 6. `+page.server.ts` / `+server.ts` use `locals.user` for identity; use the token-backed
PB client for any CRUD so PB's collection rules apply. PB client for any CRUD so PB's collection rules apply.
## Key decisions to replicate in another project Because `pb_token` is httpOnly, the browser PB SDK cannot read it from `document.cookie`.
It is instead seeded from the SSR `page.data.pbToken` prop via `initPb(token)` in the
`[fam]/+layout.svelte` `onMount`.
- **Server-only PB client** lives in `src/lib/server/`; never imported by browser code. ## Signup flow (`/signup`)
Secrets (superuser creds) stay server-side.
- **Absolute PB base URL** in the SDK (e.g. `http://host:8090`), NOT a relative `/pb`.
All calls run in Node where relative URLs fail. The Vite `/pb` proxy is a browser-only
convenience and is unnecessary for server-side calls.
- **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`),
never `$app/env/public`.
- **`createSuperPbClient`** = anonymous client + `_superusers` auth, used for admin-style
creates (signup). Superusers bypass PB collection rules.
- **Session cookie**: PB JWT in `pb_session`; expiry governed by the token's `exp`, cookie
`maxAge` is just a ceiling; `authRefresh` rolls it forward.
- **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code.
---
# Signup flow (`/signup`)
Multi-step form on a single route. All steps run as **form actions** on the server; the Multi-step form on a single route. All steps run as **form actions** on the server; the
`use:enhance` handler only advances the step on a non-failure result. `use:enhance` handler only advances the step on a non-failure result.
``` ```
ADMIN USER Step 1 (?/signup) familyName + yourName + email + password
+-----------------------------------------+ 1. POST ?/signup {familyName,email,password} · create fams → fam (slug = slugify(familyName))
| +-----------+ +-----------+ +--------+ | ─────────────────────────────► · create users → parent (role 'parent', name = yourName, username = `{famSlug}:{handle(yourName)}`)
| | step 1 | | step 2 | | step 3 | | (use:enhance advances step on success) · create settings
| | family+ | | username | | child | | · authWithPassword(email, password) → set httpOnly pb_token cookie → step 2
| | email+pass| | | | name | |
| +-----------+ +-----------+ +--------+ |
+-----------------------------------------+
▲ 3. set httpOnly cookie pb_session
│ return {success:true}
▼
┌────────────────────────────────────────────────────────────────────────┐
│ /signup/+page.server.ts actions: signup · username · child │
└────────────────────────────────────────────────────────────────────────┘
│
?/signup │ ?/username ?/child
─────────────────────┼───────────────────┬───────────────────────
signupAdmin() │ setUsername() │ createChild()
─────────────────────┼───────────────────┴───────────────────────
│ ▼ (famId from locals.user)
▼
createSuperPbClient() ── superuser-authenticated PB client
│
├─(1) fams.create({ name, slug }) → family.id
├─(2) users.create({ email,password,name, → user, role not set
│ famId: family.id })
├─(3) users.authWithPassword(email,password) → token
└─(4) setSessionCookie(cookies, token)
│
▼
hooks.server.ts picks up the cookie next request
→ authRefresh → event.locals.user populated → user is "logged in"
Failure path: any PB error → throw SignupError(msg) Step 2 (?/child) child's first name (optional)
→ action returns fail(status, { message }) · issueAccess() → upserts child user + OTP → returns { code, joinUrl } → step 3
→ FE renders form.message, does NOT advance step · or "Skip for now" → dashboard
Step 3 show OTP join code + joinUrl (+ "Go to dashboard" link)
``` ```
## Signup step detail All create calls use the superuser client (`createSuperClient` / `pbAdmin`), which bypasses
PB collection rules. PB requires a `username` on `users` auth records — for both parents and
children it's the composite `{famSlug}:{handle}` (globally unique so PB's auth-identity
unique index holds, even though the URL segment is only per-family), where `handle` is the
whitespace-free lowercase form of the name (`"Joe Edhook"` → `joeedhook`). `name` keeps the
raw human-entered display name. The URL segment is `handleOf(username)` (part after the last
`:`) → `/{famSlug}/{handle}`; parents still authenticate with email+password and land on the
fam dashboard `/{famSlug}`.
| Step | Action | Server fn | Writes | Returns | ## Child join flow (`/{famSlug}/join/{username}?code=…`)
| ---- | ------------ | ------------- | --------------------- | ---------------------------- |
| 1 | `?/signup` | `signupAdmin` | fams + users | cookie set, `{success:true}` |
| 2 | `?/username` | `setUsername` | users.name | `{success:true, username}` |
| 3 | `?/child` | `createChild` | members (name, famId) | `{success:true, code}` |
## Notes / caveats in the current code 1. Admin issues a child in Settings → `issueAccess` (`member-otp.ts`):
`createChild({ name, famId, famSlug })` upserts the `users` record
(`username = {famSlug}:{handle(name)}`, `name` = display, password = derived), and upserts a
`user_configs` row with a 20-min `otp`. Returns `{ otp, joinUrl }`.
2. The join page auto-fills the OTP from the `?code=` query param; the child submits the form.
3. `redeemOtp` verifies the fam slug, the child role, the OTP + its TTL, then
`authWithPassword(famUsername(famSlug, handle), derivePassword(famSlug, handle))` and returns a fresh JWT,
which is set as the `pb_token` cookie.
4. Child is redirected to their own kanban `/{famSlug}/{username}`.
- `users` collection has **no `role`/`username`** fields yet — `setUsername` writes to ## Key decisions to replicate in another project
`name`, and `role` isn't persisted (hooks reads it as `undefined`). Add `role` +
`username` to `users` if you need role-based guards (`/admin` relies on `role==='admin'`). - **Server-only PB client** lives in `src/lib/server/`; never imported by browser code.
- `createChild` writes a `members` record with `inviteCode`, but `members` has no Secrets (superuser creds, `MEMBER_SECRET`) stay server-side.
`inviteCode` field (it's dropped). The member is created without a `users` auth record, - **Absolute PB base URL** in the SDK (e.g. `http://host:8090`), NOT a relative `/pb`.
so it can't log in yet — see "Extending — Child / device accounts (Option C)" in the All calls run in Node where relative URLs fail. The Vite `/pb` proxy is a browser-only
root `readme.md`. convenience and is unnecessary for server-side calls.
- `fams.createRule` is `null` (admin-only), `users.createRule` is `""` (public); signup - **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`),
uses a superuser client, so both work regardless. never `$app/env/public`.
- **`createSuperClient`** = anonymous client + `_superusers` auth, used for signup and OTP
writes. Superusers bypass PB collection rules.
- **Session cookie**: PB JWT in `pb_token`, `httpOnly:true`; expiry governed by the token's
`exp`, cookie `maxAge` is just a ceiling; `authRefresh` rolls it forward.
- **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code.
- **Child identity** is a `users` record; `username` (slug) is used for URLs and the derived
password, `name` for display. Never store raw `deviceToken`; the OTP gate is transient.
+4 -12
View File
@@ -1,20 +1,12 @@
import { PocketBase } from 'pocketbase'; import type { SessionUser } from './lib/server/types';
import type { Session } from './lib/types';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {}
interface Locals { interface Locals {
pb: PocketBase; user: SessionUser | null;
session?: Session | null; pbToken: string | null;
} }
// interface PageData {}
// interface PageState {}
// interface Platform {}
} }
} }
export {}; export {};
+4 -1
View File
@@ -15,5 +15,8 @@ export const variables = defineEnvVars({
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
// PB superuser creds (server-only). // PB superuser creds (server-only).
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') }, PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
PB_PASSWORD: { public: false, schema: withDefault('debug123') } PB_PASSWORD: { public: false, schema: withDefault('debug123') },
// Server-only secret used to derive a child member's PB password from
// (famSlug + username). Never expose client-side. OTP is the access gate.
MEMBER_SECRET: { public: false, schema: withDefault('famchamp-member-secret') }
}); });
+36 -7
View File
@@ -1,16 +1,45 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
const COOKIE_NAME = 'session'; import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
import type { SessionUser } from '$lib/server/types';
import { handleOf } from '@shared/slugify';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const raw = event.cookies.get(COOKIE_NAME); event.locals.user = null;
if (raw) { event.locals.pbToken = null;
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const pb = createPbClient(token);
try { try {
event.locals.session = JSON.parse(raw); // authRefresh() does two jobs in one call:
// 1. Verifies the token (PB JWTs can't be checked offline — the
// signing secret is per-record and never leaves PB), so this
// round trip IS the verification step.
// 2. Returns the current record — the only way to get
// name/role/famId, since PB doesn't embed custom fields in the
// token itself.
const { record, token: freshToken } = await pb.collection('users').authRefresh();
event.locals.user = {
id: record.id,
name: record.name || record.username || '',
username: handleOf(record.username || ''),
role: record.role || 'parent',
famId: record.famId,
color: record.color || ''
} satisfies SessionUser;
event.locals.pbToken = freshToken;
if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken);
}
} catch { } catch {
event.cookies.delete(COOKIE_NAME, { path: '/' }); // Expired, malformed, or revoked — drop it and treat as logged out.
clearSessionCookie(event.cookies);
} }
} }
return resolve(event); return resolve(event);
}; };
+2 -3
View File
@@ -6,12 +6,11 @@ async function memberFetch<T = unknown>(
method: string, method: string,
path: string, path: string,
token: string, token: string,
famId: string, _famId?: string,
body?: unknown, body?: unknown,
): Promise<T> { ): Promise<T> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'x-device-token': token, Authorization: `Bearer ${token}`,
'x-device-famid': famId,
}; };
if (body !== undefined) headers['Content-Type'] = 'application/json'; if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE_URL}${path}`, { const res = await fetch(`${BASE_URL}${path}`, {
+22 -2
View File
@@ -10,7 +10,8 @@
<div <div
class="card" class="card"
style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}" data-cols={cols}
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
class:has-accent={!!accent} class:has-accent={!!accent}
class:scroll-x={scrollX} class:scroll-x={scrollX}
> >
@@ -26,10 +27,14 @@
<style> <style>
.card { .card {
grid-column: span var(--card-cols, 1);
/* Container so card contents can react to how wide the card actually is */
container-type: inline-size;
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
.card.scroll-x { .card.scroll-x {
overflow: visible; overflow: visible;
@@ -37,6 +42,21 @@
.card.has-accent { .card.has-accent {
border-top: 3px solid var(--card-accent, #6366f1); border-top: 3px solid var(--card-accent, #6366f1);
} }
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
@media (min-width: 640px) and (max-width: 1023px) {
.card[data-cols='3'],
.card[data-cols='4'],
.card[data-cols='5'],
.card[data-cols='6'] {
grid-column: span 2;
}
}
/* Mobile (1-col grid): every card is a full row */
@media (max-width: 639px) {
.card {
grid-column: span 1;
}
}
.card-header { .card-header {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
@@ -54,4 +74,4 @@
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
</style> </style>
+15 -2
View File
@@ -2,7 +2,7 @@
let { cols = 3, children }: { cols?: number; children?: any } = $props(); let { cols = 3, children }: { cols?: number; children?: any } = $props();
</script> </script>
<div class="card-grid" style="grid-template-columns:repeat({cols}, 1fr)"> <div class="card-grid" style="--grid-cols: {cols}">
{@render children?.()} {@render children?.()}
</div> </div>
@@ -10,5 +10,18 @@
.card-grid { .card-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
} }
</style> /* Tablet: settle to 2 columns */
@media (min-width: 640px) and (max-width: 1023px) {
.card-grid {
--grid-cols: 2;
}
}
/* Mobile: single column */
@media (max-width: 639px) {
.card-grid {
--grid-cols: 1;
}
}
</style>
+3 -4
View File
@@ -4,10 +4,9 @@ const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
export const pb = new PocketBase(PB_ENDPOINT); export const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false); pb.autoCancellation(false);
export function initPbFromCookie() { export function initPb(token: string) {
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/); if (token) {
if (match) { pb.authStore.save(token, null);
pb.authStore.save(match[1], null);
return true; return true;
} }
return false; return false;
+8 -66
View File
@@ -1,9 +1,9 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public'; import { pbAdmin } from '$lib/server/pocketbase';
export function getSession(event: RequestEvent) { export function getSession(event: RequestEvent) {
return event.locals.session; return event.locals.user;
} }
export function requireAuth(event: RequestEvent) { export function requireAuth(event: RequestEvent) {
@@ -14,72 +14,14 @@ export function requireAuth(event: RequestEvent) {
return session; return session;
} }
export async function signup(email: string, password: string, famName: string, parentName?: string) {
const res = await fetch(`${PROXY_URL}/api/admin/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, famName, parentName }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Signup failed');
return data;
}
export async function login(email: string, password: string) {
console.log(email);
const res = await fetch(`${PROXY_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
return data;
}
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
const res = await fetch(`${PROXY_URL}/api/members/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, name, deviceToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Join failed');
return data;
}
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
event.cookies.set('session', JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
secure: false,
});
}
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('device_token', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
secure: false,
});
}
export function setPbTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('pb_token', token, {
httpOnly: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24,
secure: false,
});
}
export function clearSession(event: RequestEvent) { export function clearSession(event: RequestEvent) {
event.cookies.delete('session', { path: '/' }); event.cookies.delete('session', { path: '/' });
event.cookies.delete('pb_token', { path: '/' }); event.cookies.delete('pb_token', { path: '/' });
event.cookies.delete('device_token', { path: '/' }); event.cookies.delete('device_token', { path: '/' });
} }
// Resolve the fam slug + admin display name used for the post-login redirect.
export async function getFamContext(famId: string) {
const fam = await pbAdmin.getOne('fams', famId).catch(() => null);
return { famSlug: fam?.slug || famId, famName: fam?.name || '' };
}
+4 -7
View File
@@ -2,11 +2,11 @@ import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public'; import { PROXY_URL } from '$app/env/public';
function sessionHeaders(event: RequestEvent): Record<string, string> { function sessionHeaders(event: RequestEvent): Record<string, string> {
const s = event.locals.session; const u = event.locals.user;
if (!s) return {}; if (!u) return {};
return { return {
'x-session-famid': s.famId, 'x-session-famid': u.famId,
'x-session-userid': s.userId, 'x-session-userid': u.id,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
} }
@@ -93,9 +93,6 @@ export const hono = {
async verify(event: RequestEvent, famId: string) { async verify(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
}, },
async regenInvite(event: RequestEvent, famId: string) {
return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event));
},
async weeklySummary(event: RequestEvent, famId: string) { async weeklySummary(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
}, },
-57
View File
@@ -1,57 +0,0 @@
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import { SERVER_IP } from '$app/env/public';
export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
let token: string | null = null;
let tokenExpiry = 0;
async function ensureToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
});
const data = await res.json();
if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`);
token = data.token;
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
return token!;
}
export const pbAdmin = {
async getList(collection: string, filter = '') {
const t = await ensureToken();
const params = new URLSearchParams();
if (filter) params.set('filter', filter);
params.set('perPage', '200');
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
return data.items || [];
},
async getOne(collection: string, id: string) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
return data;
},
async update(collection: string, id: string, data: Record<string, unknown>) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(data),
});
const result = await res.json();
if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`);
return result;
},
};
+15 -7
View File
@@ -7,8 +7,18 @@ interface ChatInit {
actorType: 'admin' | 'member'; actorType: 'admin' | 'member';
actorName: string; actorName: string;
actorColor: string; actorColor: string;
deviceToken?: string; pbToken?: string;
memberId?: string;
}
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
// secure context (HTTPS/localhost) — over plain HTTP on a LAN it's undefined,
// so fall back to a time+random string that's still unique enough per session.
function genClientId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return Date.now().toString(36) + Math.random().toString(36).slice(2);
} }
class ChatStore { class ChatStore {
@@ -23,8 +33,7 @@ class ChatStore {
actorType = $state<'admin' | 'member'>('member'); actorType = $state<'admin' | 'member'>('member');
actorName = $state(''); actorName = $state('');
actorColor = $state(''); actorColor = $state('');
deviceToken = $state(''); pbToken = $state('');
memberId = $state('');
private unsubs: (() => void)[] = []; private unsubs: (() => void)[] = [];
private destroyed = false; private destroyed = false;
@@ -46,8 +55,7 @@ class ChatStore {
this.actorType = opts.actorType; this.actorType = opts.actorType;
this.actorName = opts.actorName; this.actorName = opts.actorName;
this.actorColor = opts.actorColor; this.actorColor = opts.actorColor;
this.deviceToken = opts.deviceToken || ''; this.pbToken = opts.pbToken || '';
this.memberId = opts.memberId || '';
if (this.initialized && this.famId === opts.famId) return; if (this.initialized && this.famId === opts.famId) return;
if (this.initPromise) { if (this.initPromise) {
@@ -185,7 +193,7 @@ class ChatStore {
async send(content: string) { async send(content: string) {
const text = content.trim(); const text = content.trim();
if (!text || !this.famId) return; if (!text || !this.famId) return;
const clientId = crypto.randomUUID(); const clientId = genClientId();
const temp: ChatMessage = { const temp: ChatMessage = {
id: 'temp-' + clientId, id: 'temp-' + clientId,
famId: this.famId, famId: this.famId,
+15 -16
View File
@@ -13,7 +13,7 @@ import type {
} from '$lib/types'; } from '$lib/types';
type CollectionName = type CollectionName =
| 'members' | 'users'
| 'chore_templates' | 'chore_templates'
| 'assigned_chores' | 'assigned_chores'
| 'completions' | 'completions'
@@ -96,9 +96,9 @@ class FamStore {
rewardsRes, rewardsRes,
seasonsRes seasonsRes
] = await Promise.all([ ] = await Promise.all([
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise< pb.collection('users').getFullList({
Member[] filter: `famId = '${famId}' && role = 'child'`
>, }) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise< pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
ChoreTemplate[] ChoreTemplate[]
>, >,
@@ -144,19 +144,18 @@ class FamStore {
} }
private async subscribe() { private async subscribe() {
const subs: { collection: CollectionName; filter?: string }[] = [ const subs: { collection: CollectionName; filter: string }[] = [
{ collection: 'members', filter: this.famId }, { collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
{ collection: 'chore_templates', filter: this.famId }, { collection: 'chore_templates', filter: `famId = '${this.famId}'` },
{ collection: 'assigned_chores', filter: this.famId }, { collection: 'assigned_chores', filter: `famId = '${this.famId}'` },
{ collection: 'completions', filter: this.famId }, { collection: 'completions', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_configs', filter: this.famId }, { collection: 'bonus_configs', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_templates', filter: this.famId }, { collection: 'bonus_templates', filter: `famId = '${this.famId}'` },
{ collection: 'rewards', filter: this.famId }, { collection: 'rewards', filter: `famId = '${this.famId}'` },
{ collection: 'seasons', filter: this.famId } { collection: 'seasons', filter: `famId = '${this.famId}'` }
]; ];
const promises = subs.map(({ collection, filter }) => { const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : '';
return pb return pb
.collection(collection) .collection(collection)
.subscribe( .subscribe(
@@ -165,7 +164,7 @@ class FamStore {
if (this.destroyed) return; if (this.destroyed) return;
this.handleRealtime(collection, data.action, data.record); this.handleRealtime(collection, data.action, data.record);
}, },
{ filter: filterStr || undefined } { filter: filter || undefined }
) )
.then((unsub) => { .then((unsub) => {
if (this.destroyed) { if (this.destroyed) {
@@ -194,7 +193,7 @@ class FamStore {
}; };
switch (collection) { switch (collection) {
case 'members': case 'users':
this.members = apply(this.members); this.members = apply(this.members);
break; break;
case 'chore_templates': case 'chore_templates':
+2 -3
View File
@@ -40,7 +40,6 @@ export interface Fam {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
inviteCode: string;
stripeCustomerId?: string; stripeCustomerId?: string;
featureFlags: Record<string, boolean>; featureFlags: Record<string, boolean>;
payday?: number; payday?: number;
@@ -54,10 +53,10 @@ export interface Fam {
export interface Member { export interface Member {
id: string; id: string;
famId: string; famId: string;
username: string;
name: string; name: string;
color: string; color: string;
deviceToken: string; role: 'child';
deviceTokenHint: string;
created: string; created: string;
updated: string; updated: string;
} }
+15 -62
View File
@@ -1,25 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import type { Action, SubmitFunction } from './$types';
let email = $state('');
let password = $state('');
let famName = $state('');
let parentName = $state('');
let error = $state('');
let submitting = $state(false);
const submit: SubmitFunction = () => {
error = '';
submitting = true;
return async ({ result, update }) => {
if (result.type === 'failure') {
error = (result.data as any)?.error || 'Something went wrong. Please try again.';
}
submitting = false;
};
};
</script> </script>
<svelte:head> <svelte:head>
@@ -52,37 +32,7 @@
<div class="signup-card"> <div class="signup-card">
<h2>Start your family</h2> <h2>Start your family</h2>
<p class="card-sub">Free to get going. Takes about a minute.</p> <p class="card-sub">Free to get going. Takes about a minute.</p>
<form method="POST" action="/signup" use:enhance={submit}> <a class="submit" href="/signup">Create my family</a>
{#if error}
<p class="form-error">{error}</p>
{/if}
<label>
Family name
<input name="famName" bind:value={famName} placeholder="The Smiths" required />
</label>
<label>
Your name
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
</label>
<label>
Email
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
</label>
<label>
Password
<input
type="password"
name="password"
bind:value={password}
placeholder="8+ characters"
minlength={8}
required
/>
</label>
<button class="submit" type="submit" disabled={submitting}>
{submitting ? 'Creating your family…' : 'Create my family'}
</button>
</form>
<p class="card-alt"> <p class="card-alt">
Already have a family? <a href="/login">Log in</a> Already have a family? <a href="/login">Log in</a>
</p> </p>
@@ -247,17 +197,20 @@
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
.submit { .submit {
margin-top: 0.25rem; display: block;
background: #4338ca; text-align: center;
color: #fff; margin-top: 0.25rem;
border: none; background: #4338ca;
border-radius: 8px; color: #fff;
padding: 0.75rem; text-decoration: none;
font-size: 1rem; border: none;
font-weight: 600; border-radius: 8px;
cursor: pointer; padding: 0.75rem;
} font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.submit:hover { background: #3730a3; } .submit:hover { background: #3730a3; }
.submit:disabled { opacity: 0.6; cursor: not-allowed; } .submit:disabled { opacity: 0.6; cursor: not-allowed; }
.card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; } .card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; }
+28 -33
View File
@@ -1,5 +1,5 @@
import { PROXY_URL } from '$app/env/public'; import { PROXY_URL } from '$app/env/public';
import { pbAdmin } from '$lib/server/pb-admin'; import { pbAdmin } from '$lib/server/pocketbase';
const HONO_URL = PROXY_URL; const HONO_URL = PROXY_URL;
@@ -20,19 +20,17 @@ async function paydayCheck(famId: string, headers: Record<string, string>) {
async function resolveChatIdentity( async function resolveChatIdentity(
api: 'admin' | 'member', api: 'admin' | 'member',
opts: { opts: {
session?: { famId: string; userId: string }; session?: { famId: string; id: string };
deviceToken?: string; pbToken?: string;
famId?: string;
} }
) { ) {
try { try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (api === 'admin' && opts.session) { if (api === 'admin' && opts.session) {
headers['x-session-famid'] = opts.session.famId; headers['x-session-famid'] = opts.session.famId;
headers['x-session-userid'] = opts.session.userId; headers['x-session-userid'] = opts.session.id;
} else if (api === 'member' && opts.deviceToken && opts.famId) { } else if (api === 'member' && opts.pbToken) {
headers['x-device-token'] = opts.deviceToken; headers['Authorization'] = `Bearer ${opts.pbToken}`;
headers['x-device-famid'] = opts.famId;
} else { } else {
return null; return null;
} }
@@ -45,46 +43,43 @@ async function resolveChatIdentity(
} }
export async function load(event) { export async function load(event) {
const session = event.locals.session || null; const session = event.locals.user;
const isParent = session !== null; const role = session?.role || 'child';
const deviceToken = event.cookies.get('device_token') || ''; const isParent = role === 'parent';
const pbToken = event.cookies.get('pb_token') || '';
let famId = ''; let famId = '';
let chat: { famId: string; actor: any } | null = null; let chat: { famId: string; actor: any } | null = null;
if (isParent) { if (session && isParent) {
famId = session.famId; famId = session.famId;
await paydayCheck(famId, { await paydayCheck(famId, {
'x-session-famid': session.famId, 'x-session-famid': session.famId,
'x-session-userid': session.userId 'x-session-userid': session.id
}); });
chat = await resolveChatIdentity('admin', { session }); chat = await resolveChatIdentity('admin', { session });
} else if (deviceToken) { } else if (role === 'child' && pbToken && session) {
try { famId = session.famId;
const res = await fetch(`${HONO_URL}/api/members/seasons`, { await paydayCheck(famId, { Authorization: `Bearer ${pbToken}` });
headers: { 'x-device-token': deviceToken } chat = await resolveChatIdentity('member', { pbToken });
});
if (res.ok) {
const body = await res.json();
famId = body.famId || '';
}
} catch {}
if (famId) {
await paydayCheck(famId, {
'x-device-token': deviceToken,
'x-device-famid': famId
});
chat = await resolveChatIdentity('member', { deviceToken, famId });
}
} }
return { return {
session, session: session
? {
famId: session.famId,
userId: session.id,
famSlug: event.params.fam,
memberName: session.name,
memberColor: session.color || '',
role: session.role
}
: null,
isParent, isParent,
role: session?.role || 'child', role,
famId, famId,
chat, chat,
deviceToken, pbToken,
// fams is superadmin-only (non-realtime). Fetched server-side for both roles. // fams is superadmin-only (non-realtime). Fetched server-side for both roles.
fam: famId fam: famId
? await pbAdmin.getOne('fams', famId).catch(() => null) ? await pbAdmin.getOne('fams', famId).catch(() => null)
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { initPbFromCookie } from '$lib/pocketbase'; import { initPb } from '$lib/pocketbase';
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { Sidebar, TopNav, Footer, Chat } from '$lib/components'; import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
@@ -45,7 +45,7 @@
}); });
onMount(() => { onMount(() => {
initPbFromCookie(); initPb(page.data.pbToken || '');
if (page.data.famId) famStore.init(page.data.famId, page.data.fam); if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
const chat = page.data.chat; const chat = page.data.chat;
if (chat?.famId && chat?.actor) { if (chat?.famId && chat?.actor) {
@@ -55,7 +55,7 @@
actorType: chat.actor.type, actorType: chat.actor.type,
actorName: chat.actor.name, actorName: chat.actor.name,
actorColor: chat.actor.color || '#6366f1', actorColor: chat.actor.color || '#6366f1',
deviceToken: page.data.deviceToken || '' pbToken: page.data.pbToken || ''
}); });
} }
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
import { hono } from '$lib/server/hono'; import { hono } from '$lib/server/hono';
export async function load(event) { export async function load(event) {
const session = event.locals.session; const session = event.locals.user;
if (!session) return {}; if (!session) return {};
const famId = session.famId; const famId = session.famId;
+2 -1
View File
@@ -4,6 +4,7 @@
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { ViewHeader, CardGrid, Card } from '$lib/components'; import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatDDMMYY } from '$lib/format'; import { formatDDMMYY } from '$lib/format';
import { handleOf } from '@shared/slugify';
Chart.register(...registerables); Chart.register(...registerables);
@@ -138,7 +139,7 @@
<span class="dot" style="background:{s.memberColor}"></span> <span class="dot" style="background:{s.memberColor}"></span>
<span class="member-name">{s.memberName}</span> <span class="member-name">{s.memberName}</span>
{#if m} {#if m}
<a href="/{famSlug}/{m.name}" class="kanban-link">Kanban →</a> <a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
{/if} {/if}
</div> </div>
<div class="donut-wrap"> <div class="donut-wrap">
@@ -5,15 +5,17 @@ import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL; const HONO_URL = PROXY_URL;
export async function load(event) { export async function load(event) {
const session = event.locals.session; const session = event.locals.user;
// Parent (session auth) → admin overview // Parent (role=parent, session auth) → admin overview.
if (session) { // Branch on role, NOT presence: a child who OTP-logged-in also has a
// `users` session (locals.user) and must not hit the parent path.
if (session && session.role === 'parent') {
const famId = session.famId; const famId = session.famId;
const famSlug = event.params.fam; const famSlug = event.params.fam;
const username = event.params.username; const username = event.params.username;
if (session.memberName && session.memberName !== username) { if (session.name && session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
const [ const [
members, members,
@@ -51,70 +53,55 @@ export async function load(event) {
}; };
} }
// Child (device token) → kanban // Child (users auth, role=child) → kanban
const deviceToken =
event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
const famSlug = event.params.fam; const famSlug = event.params.fam;
const username = event.params.username; const username = event.params.username;
if (!deviceToken) { // A child should always land on their own kanban route.
return { if (session?.username && session.username !== username) {
role: 'child', throw redirect(303, `/${famSlug}/${session.username}`);
token: '',
memberId: '',
verified: false,
famId: '',
templates: [],
assigned: [],
completions: [],
rewards: [],
bonusConfigs: [],
tallies: {}
};
} }
const pbToken = event.cookies.get('pb_token') || '';
const famId = session?.famId || '';
const childId = session?.id || '';
const empty = {
role: 'child' as const,
token: pbToken,
memberId: childId,
famId,
verified: false,
memberName: session?.name || '',
memberColor: session?.color || '',
templates: [] as never[],
assigned: [] as never[],
completions: [] as never[],
rewards: [] as never[],
bonusConfigs: [] as never[],
tallies: {} as Record<string, unknown>,
payday: 1,
paydayTime: '18:00',
timezone: 'auto'
};
if (!pbToken || !famId) return empty;
try { try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, { const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { Authorization: `Bearer ${pbToken}` }
body: JSON.stringify({ deviceToken, famSlug })
}); });
const data = await res.json(); if (!choresRes.ok) return empty;
if (!res.ok || data.name !== username) { const chores = await choresRes.json();
return {
role: 'child',
token: deviceToken,
memberId: '',
verified: false,
famId: '',
memberName: '',
memberColor: '',
templates: [],
assigned: [],
completions: [],
rewards: [],
bonusConfigs: [],
tallies: {}
};
}
let chores: any = {};
try {
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
method: 'POST',
headers: { 'x-device-token': deviceToken, 'x-device-famid': data.famId }
});
chores = await choresRes.json();
} catch {}
return { return {
role: 'child', role: 'child',
token: deviceToken, token: pbToken,
memberId: data.memberId, memberId: childId,
famId: data.famId, famId,
verified: true, verified: true,
memberName: data.name, memberName: session?.name || '',
memberColor: data.color, memberColor: session?.color || '',
templates: chores.templates || [], templates: chores.templates || [],
assigned: chores.assigned || [], assigned: chores.assigned || [],
completions: chores.completions || [], completions: chores.completions || [],
@@ -127,29 +114,14 @@ export async function load(event) {
simulateEow: !!chores.simulateEow simulateEow: !!chores.simulateEow
}; };
} catch { } catch {
return { return empty;
role: 'child',
token: deviceToken,
memberId: '',
verified: false,
famId: '',
templates: [],
assigned: [],
completions: [],
rewards: [],
bonusConfigs: [],
tallies: {},
payday: 1,
paydayTime: '18:00',
timezone: 'auto'
};
} }
} }
export const actions = { export const actions = {
setEow: async (event) => { setEow: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const on = fd.get('on') === 'true'; const on = fd.get('on') === 'true';
try { try {
@@ -161,8 +133,8 @@ export const actions = {
}, },
previewEow: async (event) => { previewEow: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
try { try {
const preview = await hono.admin.eowPreview(event, famId); const preview = await hono.admin.eowPreview(event, famId);
await hono.admin.updateSettings(event, famId, { simulateEow: true }); await hono.admin.updateSettings(event, famId, { simulateEow: true });
@@ -173,8 +145,8 @@ export const actions = {
}, },
claim: async (event) => { claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const rewardId = fd.get('id') as string; const rewardId = fd.get('id') as string;
try { try {
@@ -186,8 +158,8 @@ export const actions = {
}, },
issueAll: async (event) => { issueAll: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
try { try {
@@ -199,8 +171,8 @@ export const actions = {
}, },
revoke: async (event) => { revoke: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const completionId = fd.get('id') as string; const completionId = fd.get('id') as string;
try { try {
@@ -212,8 +184,8 @@ export const actions = {
}, },
trigger: async (event) => { trigger: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const configId = fd.get('configId') as string; const configId = fd.get('configId') as string;
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
@@ -7,6 +7,7 @@
import { formatDDMMYY, formatHumanDate } from '$lib/format'; import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types'; import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import { handleOf } from '@shared/slugify';
import { import {
weekStart as tzWeekStart, weekStart as tzWeekStart,
addDaysStr, addDaysStr,
@@ -149,7 +150,7 @@
// ─── Child View (kanban) ─── // ─── Child View (kanban) ───
let memberId = $state(data.memberId || ''); let memberId = $state(data.memberId || '');
let deviceToken = $state(data.token || ''); let pbToken = $state(data.token || '');
let famId = $state(data.famId || ''); let famId = $state(data.famId || '');
let memberName = $state(data.memberName || ''); let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1'); let memberColor = $state(data.memberColor || '#6366f1');
@@ -209,9 +210,9 @@
$effect(() => { $effect(() => {
if (role !== 'child' || !isPaydayToday) return; if (role !== 'child' || !isPaydayToday) return;
if (secondsLeft > 0 || eowFired) return; if (secondsLeft > 0 || eowFired) return;
if (!deviceToken || !famId) return; if (!pbToken || !famId) return;
eowFired = true; eowFired = true;
memberApi.payday(deviceToken, famId).catch(() => {}); memberApi.payday(pbToken, famId).catch(() => {});
}); });
function paydayWeekStart(): string { function paydayWeekStart(): string {
@@ -476,26 +477,13 @@
onMount(async () => { onMount(async () => {
if (role === 'parent') return; if (role === 'parent') return;
if (!deviceToken) {
deviceToken = localStorage.getItem('deviceToken') || '';
} else {
localStorage.setItem('deviceToken', deviceToken);
}
if (!deviceToken) {
error = 'No device token found. Use the link from your invite.';
loading = false;
return;
}
if (data.verified && data.famId && data.memberId) { if (data.verified && data.famId && data.memberId) {
memberId = data.memberId; memberId = data.memberId;
famId = data.famId; famId = data.famId;
loading = false; loading = false;
} else { } else {
error = 'Invalid device token. Use the link from your invite.'; error = 'Please open your invite link to get access.';
loading = false; loading = false;
return;
} }
}); });
@@ -558,7 +546,7 @@
} }
try { try {
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild); await memberApi.toggleCompletion(pbToken, famId, chore.id, todayChild);
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id); const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) { if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete'); famStore.applyRecord('completions', optimistic, 'delete');
@@ -644,7 +632,7 @@
<div class="card-header"> <div class="card-header">
<span class="dot" style="background:{m.color}"></span> <span class="dot" style="background:{m.color}"></span>
<span class="member-name">{m.name}</span> <span class="member-name">{m.name}</span>
<a href="/{famSlug}/{m.name}" class="link">Kanban</a> <a href="/{famSlug}/{handleOf(m.username)}" class="link">Kanban</a>
</div> </div>
<div class="stats"> <div class="stats">
<span>Points: {s?.pointsEarned ?? 0}</span> <span>Points: {s?.pointsEarned ?? 0}</span>
@@ -976,8 +964,7 @@
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-device-token': deviceToken, Authorization: `Bearer ${pbToken}`
'x-device-famid': famId
}, },
body: JSON.stringify({ name: nameInput }) body: JSON.stringify({ name: nameInput })
}); });
@@ -1039,8 +1026,7 @@
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-device-token': deviceToken, Authorization: `Bearer ${pbToken}`
'x-device-famid': famId
}, },
body: JSON.stringify({ color }) body: JSON.stringify({ color })
}); });
@@ -1267,7 +1253,7 @@
class="wr-cta" class="wr-cta"
onclick={async () => { onclick={async () => {
try { try {
await memberApi.claimReward(deviceToken, famId, r.id); await memberApi.claimReward(pbToken, famId, r.id);
} catch (e) { } catch (e) {
claimError = e instanceof Error ? e.message : 'Claim failed'; claimError = e instanceof Error ? e.message : 'Claim failed';
} }
@@ -2,8 +2,8 @@ import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono'; import { hono } from '$lib/server/hono';
export async function load(event) { export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const [configs, templates, members, progress, rewards] = await Promise.all([ const [configs, templates, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId), hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'bonus-templates', famId), hono.admin.list(event, 'bonus-templates', famId),
@@ -16,8 +16,8 @@ export async function load(event) {
export const actions = { export const actions = {
createTemplate: async (event) => { createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
name: fd.get('name'), name: fd.get('name'),
@@ -42,8 +42,8 @@ export const actions = {
}, },
updateTemplate: async (event) => { updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
@@ -75,8 +75,8 @@ export const actions = {
}, },
deleteTemplate: async (event) => { deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
try { try {
@@ -88,8 +88,8 @@ export const actions = {
}, },
createConfig: async (event) => { createConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const startMode = fd.get('startMode') as string; const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active'; const status = startMode === 'disabled' ? 'disabled' : 'active';
@@ -120,8 +120,8 @@ export const actions = {
}, },
updateConfig: async (event) => { updateConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
@@ -155,8 +155,8 @@ export const actions = {
}, },
deleteConfig: async (event) => { deleteConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
try { try {
@@ -168,8 +168,8 @@ export const actions = {
}, },
createFromTemplate: async (event) => { createFromTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const startMode = fd.get('startMode') as string; const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active'; const status = startMode === 'disabled' ? 'disabled' : 'active';
@@ -198,8 +198,8 @@ export const actions = {
}, },
assignConfig: async (event) => { assignConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const target = fd.get('target') as string; const target = fd.get('target') as string;
@@ -216,8 +216,8 @@ export const actions = {
}, },
completeConfig: async (event) => { completeConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
try { try {
@@ -229,8 +229,8 @@ export const actions = {
}, },
destroyConfig: async (event) => { destroyConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
try { try {
@@ -242,8 +242,8 @@ export const actions = {
}, },
toggleConfig: async (event) => { toggleConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const currentStatus = fd.get('currentStatus') as string; const currentStatus = fd.get('currentStatus') as string;
@@ -259,8 +259,8 @@ export const actions = {
}, },
evaluate: async (event) => { evaluate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
try { try {
await hono.admin.evaluateBonusConfig(event, famId); await hono.admin.evaluateBonusConfig(event, famId);
} catch (e) { } catch (e) {
@@ -1,35 +1,45 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono'; import { pbUser } from '$lib/server/pocketbase';
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
export async function load(event) { export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const pb = pbUser(event);
const [templates, members, assigned, seasons, completions] = await Promise.all([ const [templates, members, assigned, seasons, completions] = await Promise.all([
hono.admin.list(event, 'chore-templates', famId), pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
hono.admin.list(event, 'members', famId), pb.collection('users').getFullList({
hono.admin.list(event, 'assigned-chores', famId), filter: `famId = '${famId}' && role = 'child'`
hono.admin.list(event, 'seasons', famId), }),
hono.admin.completions(event, famId), pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
]); ]);
return { templates, members, assigned, seasons, completions }; return {
templates: templates as unknown as ChoreTemplate[],
members: members as unknown as Member[],
assigned: assigned as unknown as AssignedChore[],
seasons: seasons as unknown as Season[],
completions: completions as unknown as Completion[]
};
} }
export const actions = { export const actions = {
createTemplate: async (event) => { createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const data = { const data = {
name: fd.get('name'), name: fd.get('name'),
defaultFrequency: fd.get('defaultFrequency'), defaultFrequency: fd.get('defaultFrequency'),
defaultType: fd.get('defaultType'), defaultType: fd.get('defaultType'),
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0, defaultValue: parseFloat(fd.get('defaultValue') as string) || 0
}; };
if (!data.name || !data.defaultFrequency || !data.defaultType) { if (!data.name || !data.defaultFrequency || !data.defaultType) {
return fail(400, { error: 'Name, frequency, and type are required' }); return fail(400, { error: 'Name, frequency, and type are required' });
} }
try { try {
const record = await hono.admin.create(event, 'chore-templates', famId, data); const record = await pbUser(event).collection('chore_templates').create({ famId, ...data });
return { record }; return { record };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
@@ -37,8 +47,8 @@ export const actions = {
}, },
updateTemplate: async (event) => { updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const defaultFrequency = fd.get('defaultFrequency') as string; const defaultFrequency = fd.get('defaultFrequency') as string;
@@ -48,20 +58,25 @@ export const actions = {
name: fd.get('name'), name: fd.get('name'),
defaultFrequency, defaultFrequency,
defaultType, defaultType,
defaultValue, defaultValue
}; };
try { try {
const record = await hono.admin.update(event, 'chore-templates', famId, id, data); const pb = pbUser(event);
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId); const record = await pb.collection('chore_templates').update(id, data);
const allAssigned = await pb
.collection('assigned_chores')
.getFullList({ filter: `famId = '${famId}'` });
if (Array.isArray(allAssigned)) { if (Array.isArray(allAssigned)) {
const toUpdate = allAssigned.filter((a: any) => a.templateId === id); const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
await Promise.all(toUpdate.map((a: any) => await Promise.all(
hono.admin.update(event, 'assigned-chores', famId, a.id, { toUpdate.map((a: any) =>
frequency: defaultFrequency, pb.collection('assigned_chores').update(a.id, {
type: defaultType, frequency: defaultFrequency,
value: defaultValue, type: defaultType,
}) value: defaultValue
)); })
)
);
} }
return { record }; return { record };
} catch (e) { } catch (e) {
@@ -70,21 +85,21 @@ export const actions = {
}, },
deleteTemplate: async (event) => { deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
try { try {
const record = await hono.admin.remove(event, 'chore-templates', famId, id); await pbUser(event).collection('chore_templates').delete(id);
return { record }; return {};
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
} }
}, },
updateAssignedChore: async (event) => { updateAssignedChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const isTodo = fd.get('isTodo') === '1'; const isTodo = fd.get('isTodo') === '1';
@@ -116,7 +131,7 @@ export const actions = {
} }
} }
try { try {
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data); const record = await pbUser(event).collection('assigned_chores').update(id, data);
return { record }; return { record };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
@@ -124,8 +139,8 @@ export const actions = {
}, },
createTodo: async (event) => { createTodo: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
const name = fd.get('name') as string; const name = fd.get('name') as string;
@@ -167,15 +182,14 @@ export const actions = {
customName: name, customName: name,
isTodo: true, isTodo: true,
startDate, startDate,
completeBy, completeBy
}; };
try { try {
const record = await hono.admin.create(event, 'assigned-chores', famId, data); const record = await pbUser(event).collection('assigned_chores').create({ famId, ...data });
return { record }; return { record };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' });
} }
}, }
};
};
@@ -2,8 +2,8 @@ import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono'; import { hono } from '$lib/server/hono';
export async function load(event) { export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const [rewards, members, assigned, templates, completions] = await Promise.all([ const [rewards, members, assigned, templates, completions] = await Promise.all([
hono.admin.rewards(event, famId), hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId), hono.admin.list(event, 'members', famId),
@@ -16,8 +16,8 @@ export async function load(event) {
export const actions = { export const actions = {
claim: async (event) => { claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.user.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const rewardId = fd.get('id') as string; const rewardId = fd.get('id') as string;
try { try {
@@ -5,15 +5,15 @@ import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL; const HONO_URL = PROXY_URL;
export async function load(event) { export async function load(event) {
const session = event.locals.session; const session = event.locals.user;
const famSlug = event.params.fam; const famSlug = event.params.fam;
const username = event.params.username; const username = event.params.username;
// Parent (session auth) — profile lives in fam_admins // Parent (session auth) — profile lives on the users record
if (session) { if (session) {
const famId = session.famId; const famId = session.famId;
if (session.memberName && session.memberName !== username) { if (session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`); throw redirect(303, `/${famSlug}/${session.username}/preferences`);
} }
try { try {
const me = await hono.admin.getProfile(event, famId); const me = await hono.admin.getProfile(event, famId);
@@ -54,7 +54,7 @@ export async function load(event) {
export const actions = { export const actions = {
update: async (event) => { update: async (event) => {
const session = event.locals.session; const session = event.locals.user;
const fd = await event.request.formData(); const fd = await event.request.formData();
const name = fd.get('name') as string; const name = fd.get('name') as string;
const color = fd.get('color') as string; const color = fd.get('color') as string;
@@ -1,55 +1,81 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase';
import { pbAdmin } from '$lib/server/pocketbase';
import { hono } from '$lib/server/hono';
import { issueAccess, createChild } from '$lib/server/member-otp';
import { slugify } from '@shared/slugify';
function famIdOf(event: RequestEvent): string {
if (!event.locals.user) throw redirect(303, '/login');
return event.locals.user.famId;
}
export async function load(event: RequestEvent) { export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId; const pb = pbUser(event);
const [members, fam, seasons] = await Promise.all([ const [members, fam, seasons] = await Promise.all([
hono.admin.list(event, 'members', famId), pb.collection('users').getFullList({
hono.admin.fam(event, famId), filter: `famId = '${famId}' && role = 'child'`
hono.admin.list(event, 'seasons', famId), }),
pbAdmin.getOne('fams', famId),
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
]); ]);
return { members, fam, seasons }; return { members, fam, seasons };
} }
export const actions = { export const actions = {
regenInvite: async (event: RequestEvent) => { addMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId; const famSlug: string = event.params.fam as string;
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode }; const fd = await event.request.formData();
const name = (fd.get('name') as string) || '';
const colour = (fd.get('colour') as string) || '#6366f1';
if (!name) return { error: 'Name required' };
try {
await createChild({ famId, famSlug, name, colour });
return { ok: true };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to add member' };
}
}, },
addMember: async (event: RequestEvent) => { issueAccess: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId; const famSlug: string = event.params.fam as string;
const fd = await event.request.formData(); const fd = await event.request.formData();
const name = fd.get('name') as string; const name = (fd.get('name') || '').toString().trim();
if (!name) return { error: 'Name required' }; if (!name) return { error: 'Select a child to invite' };
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' }); try {
const result = await issueAccess({ famId, famSlug, name });
return { ok: true, ...result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to issue access' };
}
}, },
renameFam: async (event: RequestEvent) => { renameFam: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const name = fd.get('name') as string; const name = fd.get('name') as string;
if (!name) return { error: 'Name required' }; if (!name) return { error: 'Name required' };
return await hono.admin.renameFam(event, famId, name); const record = await pbUser(event)
.collection('fams')
.update(famId, { name, slug: slugify(name) });
return { name: record.name, slug: record.slug };
}, },
deleteMember: async (event: RequestEvent) => { deleteMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
if (!id) return { error: 'Member ID required' }; if (!id) return { error: 'Member ID required' };
await hono.admin.remove(event, 'members', famId, id); await pbUser(event).collection('users').delete(id);
return { ok: true };
}, },
updatePayday: async (event: RequestEvent) => { updatePayday: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const payday = parseInt(fd.get('payday') as string, 10); const payday = parseInt(fd.get('payday') as string, 10);
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' }; if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
@@ -59,43 +85,48 @@ export const actions = {
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) { if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
return { error: 'Timezone must be an IANA name or auto' }; return { error: 'Timezone must be an IANA name or auto' };
} }
return await hono.admin.updatePayday(event, famId, payday, paydayTime, timezone || undefined); const patch: Record<string, unknown> = { payday };
if (paydayTime) patch.paydayTime = paydayTime;
if (timezone) patch.timezone = timezone;
const record = await pbUser(event).collection('fams').update(famId, patch);
return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone };
}, },
createSeason: async (event: RequestEvent) => { createSeason: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const name = fd.get('name') as string; const name = fd.get('name') as string;
const color = fd.get('color') as string; const color = fd.get('color') as string;
if (!name) return { error: 'Name required' }; if (!name) return { error: 'Name required' };
return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true }); return await pbUser(event)
.collection('seasons')
.create({ famId, name, color: color || '#6366f1', active: true });
}, },
deleteSeason: async (event: RequestEvent) => { deleteSeason: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
if (!id) return { error: 'Season ID required' }; if (!id) return { error: 'Season ID required' };
const assigned = await hono.admin.list(event, 'assigned-chores', famId); const pb = pbUser(event);
const toDelete = (Array.isArray(assigned) ? assigned : []) const assigned = await pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` });
.filter((a: any) => a.seasonIds?.includes(id)); const toDelete = (Array.isArray(assigned) ? assigned : []).filter(
(a: any) => a.seasonIds?.includes(id)
);
const deletedIds = toDelete.map((a: any) => a.id); const deletedIds = toDelete.map((a: any) => a.id);
await Promise.all(toDelete.map((a: any) => await Promise.all(
hono.admin.remove(event, 'assigned-chores', famId, a.id) toDelete.map((a: any) => pb.collection('assigned_chores').delete(a.id))
)); );
await pb.collection('seasons').delete(id);
await hono.admin.remove(event, 'seasons', famId, id);
return { deletedChoreIds: deletedIds }; return { deletedChoreIds: deletedIds };
}, },
// Compute endpoints — still proxied to Hono.
completeWeek: async (event: RequestEvent) => { completeWeek: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
try { try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`); const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
return { success: true, result }; return { success: true, result };
@@ -105,15 +136,14 @@ export const actions = {
}, },
generateData: async (event: RequestEvent) => { generateData: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login'); const famId = famIdOf(event);
const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const days = parseInt(fd.get('days') as string || '7', 10); const days = parseInt((fd.get('days') as string) || '7', 10);
try { try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days }); const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
return { success: true, result }; return { success: true, result };
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to generate data' }; return { error: e instanceof Error ? e.message : 'Failed to generate data' };
} }
}, }
}; };
@@ -4,20 +4,20 @@
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { COMMON_TIMEZONES } from '@shared/timezone'; import { COMMON_TIMEZONES } from '@shared/timezone';
import { handleOf } from '@shared/slugify';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
let { data } = $props(); let { data } = $props();
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the // fam is sensitive (stripeCustomerId, featureFlags) — never in the
// public famStore stream. It is superadmin-only, fetched server-side by the // public famStore stream. It is superadmin-only, fetched server-side by the
// layout load. Writes go through form actions; no live fam subscription. // layout load. Writes go through form actions; no live fam subscription.
let fam = $state(data.fam); let fam = $state(data.fam);
let famSlug = $state(page.params.fam); let famSlug = $state(page.params.fam);
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`);
let addName = $state(''); let addName = $state('');
let rename = $state(''); let rename = $state('');
let selectedMember = $state(''); let inviteChild = $state('');
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1); let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
let paydayTime = $state(fam?.paydayTime || '18:00'); let paydayTime = $state(fam?.paydayTime || '18:00');
let paydayTimes = $state([ let paydayTimes = $state([
@@ -56,12 +56,16 @@
let copied = $state(false); let copied = $state(false);
let parentInviteEmail = $state(''); let parentInviteEmail = $state('');
let selectedInviteUrl = $derived(
selectedMember ? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}` : childInviteUrl
);
let members = $state(famStore.initialized ? famStore.members : data.members || []); let members = $state(famStore.initialized ? famStore.members : data.members || []);
let deletingSeason = $state<any>(null); let deletingSeason = $state<any>(null);
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let invitePath = $derived(
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
);
let inviteUrl = $derived(
issued ? `${page.url.origin}${invitePath}` : ''
);
function copy(url: string) { function copy(url: string) {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
@@ -73,6 +77,15 @@
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 }); qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
} }
function toggleQR() {
showQR = !showQR;
if (!showQR) {
qrDataUrl = '';
} else {
generateQR(inviteUrl);
}
}
function handleParentInvite() { function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail); alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
} }
@@ -82,37 +95,60 @@
<CardGrid> <CardGrid>
<Card title="Family Name"> <Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance> <form method="POST" action="?/renameFam" use:enhance>
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required /> <label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button> <Button type="submit" size="sm">Rename</Button>
</form> </form>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</Card> </Card>
<Card title="Members ({members.length})" cols={2}> <Card title="Members ({members.length})" cols={2}>
<form method="POST" action="?/addMember" use:enhance> <div class="members-grid">
<input name="name" bind:value={addName} placeholder="Member name" required /> <div class="members-add">
<Button type="submit" size="sm">Add</Button> <p class="hint">Add a child. They'll pick their own colour after joining.</p>
</form> <form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
<ul> <ul class="members-list">
{#each members as m} {#each members as m}
<li> <li>
<span class="dot" style="background:{m.color}"></span> <span class="member-left">
{m.name} <span class="member-color" style="background:{m.color}"></span>
{m.deviceToken ? '' : ' (pending join)'} <span class="member-info">
<a href="/{famSlug}/{m.name}" class="link">Kanban</a> <span class="member-name">{m.name}</span>
<form method="POST" action="?/deleteMember" use:enhance class="inline"> <span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
<input type="hidden" name="id" value={m.id} /> </span>
<Button </span>
type="submit" <span class="member-actions">
variant="danger" <Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
size="sm" <form method="POST" action="?/deleteMember" use:enhance class="inline">
onclick={() => confirm('Remove {m.name}?')}>Remove</Button <input type="hidden" name="id" value={m.id} />
> <Button
</form> type="submit"
</li> variant="danger"
{/each} size="sm"
</ul> onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card> </Card>
<Card title="Payday" cols={1}> <Card title="Payday" cols={1}>
@@ -166,54 +202,70 @@
</Card> </Card>
<Card title="Invite Children" cols={1}> <Card title="Invite Children" cols={1}>
<p class="code">{fam?.inviteCode || '...'}</p> <form
method="POST"
<div class="invite-row"> action="?/issueAccess"
<label>Member:</label> use:enhance={() => {
<select bind:value={selectedMember}> return async ({ formData, result }) => {
<option value="">— General link —</option> if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m} {#each members as m}
<option value={m.name}>{m.name}</option> <option value={m.name}>{m.name}</option>
{/each} {/each}
</select> </select>
</div> <Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
<p class="hint">Re-joining a member will disconnect their old device.</p> </form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
<p class="invite-url">{selectedInviteUrl}</p> {#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<div class="actions"> <p class="text-xs text-slate-500">
<Button onclick={() => copy(selectedInviteUrl)} size="sm"> Code for {issued.name} (valid 20 min):
{copied ? 'Copied!' : 'Copy link'} </p>
</Button> <p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
{issued.otp}
<Button </p>
onclick={async () => { <p class="invite-url">{invitePath}</p>
showQR = !showQR; <div class="actions justify-center">
if (!showQR) { <Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
qrDataUrl = ''; {copied ? 'Copied!' : 'Copy URL'}
} else { </Button>
await generateQR(selectedInviteUrl); <Button variant="secondary" size="sm" onclick={toggleQR}>
} {showQR ? 'Hide QR' : 'Show QR'}
}} </Button>
size="sm" </div>
> {#if showQR && qrDataUrl}
{showQR ? 'Hide QR' : 'Show QR'} <div class="qr-wrap">
</Button> <img src={qrDataUrl} alt="QR Code" class="qr" />
</div>
<form method="POST" action="?/regenInvite" use:enhance class="inline"> {/if}
<Button type="submit" size="sm">Renew code</Button> </div>
</form>
</div>
{#if showQR && qrDataUrl}
<img src={qrDataUrl} alt="QR Code" class="qr" />
{/if} {/if}
</Card> </Card>
<Card title="Invite Parent" cols={1}> <Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p> <p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-row"> <div class="invite-form">
<input type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" /> <label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button> <Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div> </div>
<p class="hint">They will set up their own password on first login.</p> <p class="hint">They will set up their own password on first login.</p>
@@ -222,9 +274,13 @@
<Card title="Seasons" cols={1}> <Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p> <p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="inline"> <form method="POST" action="?/createSeason" use:enhance class="season-form">
<input name="name" placeholder="Season name" required /> <label class="field-label" for="season-name">New season</label>
<input name="color" type="color" value="#6366f1" class="color-input" /> <input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button> <Button type="submit" size="sm">Add</Button>
</form> </form>
@@ -310,54 +366,74 @@
</CardGrid> </CardGrid>
<style> <style>
.section {
margin: 0.5rem 0;
}
.code {
font-family: monospace;
font-size: 1.2rem;
padding: 0.5rem;
background: #f3f4f6;
border-radius: 4px;
display: inline-block;
}
.invite-url {
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
background: #f9fafb;
padding: 0.4rem;
border-radius: 4px;
}
.invite-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.5rem 0;
}
.invite-row select {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1;
}
.invite-row input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1;
}
.hint { .hint {
font-size: 0.85rem; font-size: 0.85rem;
color: #9ca3af; color: #9ca3af;
line-height: 1.4;
} }
.payday-form { .invite-url {
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 0.6rem;
border-radius: 8px;
}
/* ── Forms: full-width, balanced fields ── */
form {
display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; gap: 0.6rem;
margin-bottom: 1rem;
} }
.payday-form button[type='submit'] { form.inline {
align-self: flex-start; display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
} }
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
input,
select {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
font-size: 0.9rem;
}
input:focus,
select:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
border-color: #6366f1;
}
/* Buttons are rendered by <Button> (scoped in its own component) — reach
them with :global so primary CTAs stretch to full width. */
form :global(.btn) {
width: 100%;
}
form.inline :global(.btn) {
width: auto;
}
.invite-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.invite-form :global(.btn) {
width: 100%;
}
/* ── Payday ── */
.payday-row { .payday-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -373,31 +449,51 @@
font-size: 0.8rem; font-size: 0.8rem;
color: #6b7280; color: #6b7280;
flex-shrink: 0; flex-shrink: 0;
font-weight: 500;
} }
.payday-row + .payday-row { .payday-row + .payday-row {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.actions {
/* ── Colour rows (seasons) ── */
.color-row {
display: flex; display: flex;
gap: 0.5rem; align-items: center;
margin-top: 0.5rem; gap: 0.75rem;
flex-wrap: wrap;
} }
.qr { .color-row label {
margin-top: 0.5rem; font-size: 0.8rem;
border: 1px solid #e5e7eb; font-weight: 500;
border-radius: 4px; color: #6b7280;
flex-shrink: 0;
} }
.color-input {
width: 100%;
max-width: 160px;
height: 36px;
padding: 2px;
border: 1px solid #d1d5db;
border-radius: 8px;
cursor: pointer;
}
/* ── Lists ── */
ul { ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0;
} }
li { li {
padding: 0.3rem 0; padding: 0.45rem 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
}
li:last-child {
border-bottom: none;
} }
.dot { .dot {
display: inline-block; display: inline-block;
@@ -410,18 +506,121 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #6366f1; color: #6366f1;
} }
form { .slug-inline {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
}
.slug-line {
margin-top: 0.75rem;
}
/* ── Members: two-column (add | list) ── */
.members-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.members-add form {
margin-bottom: 0;
}
.members-list {
border-left: 1px solid #f3f4f6;
padding-left: 1.25rem;
}
.member-left,
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-color {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.member-name {
font-weight: 500;
color: #374151;
}
.member-info {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.member-handle {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
color: #9ca3af;
}
.member-actions :global(.btn) {
flex: none;
width: auto;
}
@container (max-width: 380px) {
.members-grid {
grid-template-columns: 1fr;
}
.members-list {
border-left: none;
padding-left: 0;
border-top: 1px solid #f3f4f6;
padding-top: 0.5rem;
}
}
/* ── Action rows ── */
.actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-top: 0.75rem;
flex-wrap: wrap;
} }
input, .actions.justify-center {
select, justify-content: center;
button {
padding: 0.4rem 0.7rem;
border: 1px solid #ccc;
border-radius: 4px;
} }
.actions :global(.btn) {
flex: 1;
}
.qr-wrap {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr {
border: 1px solid #e5e7eb;
border-radius: 8px;
}
/* ── Narrow cards: stack rows / actions full width (container query) ── */
@container (max-width: 380px) {
.payday-row {
flex-direction: column;
align-items: stretch;
}
.payday-row select {
width: 100%;
}
.color-row {
align-items: stretch;
}
.actions {
flex-direction: column;
}
.actions :global(.btn) {
flex: none;
width: 100%;
}
}
/* ── Overlay / modal (plain buttons live in this template) ── */
button { button {
background: #6366f1; background: #6366f1;
color: white; color: white;
@@ -437,18 +636,6 @@
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
} }
.inline {
display: inline;
margin: 0;
}
.color-input {
width: 40px;
height: 34px;
padding: 0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -462,8 +649,9 @@
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
min-width: 320px; width: 100%;
max-width: 440px; max-width: 440px;
margin: 0 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
} }
.modal h3 { .modal h3 {
+6 -6
View File
@@ -1,4 +1,4 @@
import { pbAdmin } from '$lib/server/pb-admin'; import { pbAdmin } from '$lib/server/pocketbase';
import { redirect, fail } from '@sveltejs/kit'; import { redirect, fail } from '@sveltejs/kit';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -12,17 +12,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
try { try {
const fams = await pbAdmin.getList('fams'); const fams = await pbAdmin.getList('fams');
const famsWithStats = await Promise.all(fams.map(async (fam: any) => { const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
const [members, rewards, famAdmins] = await Promise.all([ const [members, rewards, parents] = await Promise.all([
pbAdmin.getList('members', `famId = '${fam.id}'`), pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
pbAdmin.getList('rewards', `famId = '${fam.id}'`), pbAdmin.getList('rewards', `famId = '${fam.id}'`),
pbAdmin.getList('fam_admins', `famId = '${fam.id}'`), pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`),
]); ]);
return { return {
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode, id: fam.id, name: fam.name, slug: fam.slug,
memberCount: members.length, memberCount: members.length,
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length, requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
totalRewards: rewards.length, totalRewards: rewards.length,
parentEmail: (famAdmins as any[])?.[0]?.email || '', parentEmail: (parents as any[])?.[0]?.email || '',
featureFlags: fam.featureFlags || {}, featureFlags: fam.featureFlags || {},
}; };
})); }));
+8 -8
View File
@@ -16,21 +16,21 @@ export async function POST(event: RequestEvent) {
const body = (await event.request.json().catch(() => null)) as Body | null; const body = (await event.request.json().catch(() => null)) as Body | null;
if (!body || !body.action) return json({ error: 'missing action' }, 400); if (!body || !body.action) return json({ error: 'missing action' }, 400);
const session = event.locals.session; const session = event.locals.user;
const deviceToken = event.cookies.get('device_token') || ''; const pbToken = event.cookies.get('pb_token') || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
let famId = body.famId || ''; let famId = body.famId || '';
if (session?.famId && session?.userId) { if (session?.role === 'parent' && session?.famId && session?.id) {
// Admin (parent) — trust the verified session server-side. // Admin (parent) — trust the verified session server-side.
headers['x-session-famid'] = session.famId; headers['x-session-famid'] = session.famId;
headers['x-session-userid'] = session.userId; headers['x-session-userid'] = session.id;
famId = session.famId;
} else if (session?.role === 'child' && pbToken && session?.famId) {
// Member (child) — forward the pb_token; the proxy re-validates.
headers['Authorization'] = `Bearer ${pbToken}`;
famId = session.famId; famId = session.famId;
} else if (deviceToken) {
// Member (child) — forward the device token; the proxy re-validates.
headers['x-device-token'] = deviceToken;
headers['x-device-famid'] = famId;
} else { } else {
return json({ error: 'Unauthorized' }, 401); return json({ error: 'Unauthorized' }, 401);
} }
@@ -1,24 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import { joinMember, setDeviceTokenCookie } from '$lib/server/auth';
export const actions = {
default: async (event) => {
const code = event.params.code;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) {
return fail(400, { error: 'Name is required' });
}
const deviceToken = crypto.randomUUID();
try {
const result = await joinMember(code, name, deviceToken);
setDeviceTokenCookie(event, deviceToken);
throw redirect(303, `/${result.famSlug}/${result.name}`);
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' });
}
},
};
@@ -1,67 +0,0 @@
<script lang="ts">
import AuthShell from '$lib/components/AuthShell.svelte';
let { form } = $props();
let name = $state('');
</script>
<AuthShell title="Join your family" subtitle="Enter your name to join. It must match a member slot created by your admin.">
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
<form method="POST">
<label>
Your name
<input name="name" bind:value={name} placeholder="Your exact name" required />
</label>
<button type="submit">Join</button>
</form>
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #3730a3; }
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
</style>
@@ -1,24 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import { setDeviceTokenCookie } from '$lib/server/auth';
import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL;
export const actions = {
default: async (event) => {
const code = event.params.code;
const name = event.params.member;
const res = await fetch(`${HONO_URL}/api/members/direct-join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode: code, name }),
});
const data = await res.json();
if (!res.ok) return fail(400, { error: data.error || 'Join failed' });
setDeviceTokenCookie(event, data.deviceToken);
throw redirect(303, `/${data.famSlug}/${data.name}`);
},
};
@@ -1,45 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import AuthShell from '$lib/components/AuthShell.svelte';
let { form } = $props();
let memberName = $derived(page.params.member);
</script>
<AuthShell
title="Join as {memberName}"
subtitle="You've been invited to your family's chore board. One tap and you're in."
>
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
<form method="POST" use:enhance>
<button type="submit">Join as {memberName}</button>
</form>
</AuthShell>
<style>
button {
width: 100%;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #3730a3; }
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
</style>
+31 -19
View File
@@ -1,10 +1,12 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth'; import { createPbClient } from '$lib/server/pocketbase';
import { setSessionCookie } from '$lib/server/session';
import { pbAdmin } from '$lib/server/pocketbase';
import { handleOf } from '@shared/slugify';
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
const fd = await event.request.formData();
const fd = await event.request.formData();
const email = fd.get('email') as string; const email = fd.get('email') as string;
const password = fd.get('password') as string; const password = fd.get('password') as string;
@@ -12,22 +14,32 @@ export const actions = {
return fail(400, { error: 'Email and password required', email }); return fail(400, { error: 'Email and password required', email });
} }
let result: any; let authResult: { token: string; record: any };
try { try {
result = await login(email, password); authResult = await createPbClient()
} catch (e) { .collection('users')
return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email }); .authWithPassword(email, password);
} catch {
return fail(400, { error: 'Invalid email or password', email });
} }
setSessionCookie(event, { const user = authResult.record;
famId: result.famId, if (!user.famId) {
userId: result.userId, return fail(400, { error: 'No family linked to this account', email });
famSlug: result.famSlug, }
memberName: result.memberName,
role: result.role,
});
setPbTokenCookie(event, result.token);
throw redirect(303, `/${result.famSlug}/${result.memberName}`); setSessionCookie(event.cookies, authResult.token);
},
}; const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
const famSlug = fam?.slug || user.famId;
// Parents (admins) land on the fam dashboard — no username in the URL.
if (user.role === 'parent') {
throw redirect(303, `/${famSlug}`);
}
// Children don't log in via email; this is just a safe fallback.
const handle = handleOf(user.username || '') || user.name || email.split('@')[0];
throw redirect(303, `/${famSlug}/${handle}`);
}
};
+11 -5
View File
@@ -1,14 +1,20 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { clearSession } from '$lib/server/auth'; import { clearSessionCookie } from '$lib/server/session';
function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies);
event.cookies.delete('session', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
}
export function load(event) { export function load(event) {
clearSession(event); signOut(event);
throw redirect(303, '/'); throw redirect(303, '/');
} }
export const actions = { export const actions = {
default: (event) => { default: (event) => {
clearSession(event); signOut(event);
throw redirect(303, '/'); throw redirect(303, '/');
}, }
}; };
+78 -19
View File
@@ -1,35 +1,94 @@
import { fail, isRedirect, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth'; import type { RequestEvent } from '@sveltejs/kit';
import { pbAdmin } from '$lib/server/pocketbase';
import { createPbClient } from '$lib/server/pocketbase';
import { setSessionCookie } from '$lib/server/session';
import { issueAccess } from '$lib/server/member-otp';
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
class SignupError extends Error {}
export const actions = { export const actions = {
default: async (event) => { // Step 1 — create the family + parent (admin) user, mint their session.
// The parent's human-entered name is kept as the display `name`; their PB
// `username` is `{famSlug}:{handle}` (globally unique, handle = no whitespace).
signup: async (event) => {
const fd = await event.request.formData(); const fd = await event.request.formData();
const famName = fd.get('familyName') as string;
const yourName = (fd.get('yourName') as string) || '';
const email = fd.get('email') as string; const email = fd.get('email') as string;
const password = fd.get('password') as string; const password = fd.get('password') as string;
const famName = fd.get('famName') as string;
const parentName = fd.get('parentName') as string;
if (!email || !password || !famName) { if (!famName || !yourName || !email || !password) {
return fail(400, { error: 'All fields required', email, famName }); return fail(400, { message: 'All fields required', famName, email });
} }
if (password.length < 8) { if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', email, famName }); return fail(400, { message: 'Password must be at least 8 characters', famName, email });
} }
const parentName = yourName.trim();
const slug = slugify(famName);
const handleName = handle(parentName) || 'admin';
const username = famUsername(slug, handleName);
try { try {
const result = await signup(email, password, famName, parentName || email.split('@')[0]); const fam = await pbAdmin.create('fams', {
setSessionCookie(event, { name: famName,
famId: result.famId, slug,
userId: result.userId, timezone: 'auto'
famSlug: result.famSlug,
memberName: result.memberName,
role: result.role,
}); });
setPbTokenCookie(event, result.token); const user = await pbAdmin.create('users', {
throw redirect(303, `/${result.famSlug}/${result.memberName}`); username,
name: parentName,
email,
password,
passwordConfirm: password,
emailVisibility: false,
famId: fam.id,
role: 'parent'
});
await pbAdmin.create('settings', { famId: fam.id });
} catch (e) { } catch (e) {
if (isRedirect(e)) throw e; throw new SignupError(
return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName }); `Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
);
} }
// Auth as the new parent to mint their JWT, then move to the child step.
const authResult = await createPbClient()
.collection('users')
.authWithPassword(email, password)
.catch(() => null);
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
// `username` here is the handle (URL segment), not the composite.
return { success: true, famSlug: slug, username: handleName };
}, },
// Step 2 — optionally add a child now; issues their OTP join code.
child: async (event: RequestEvent) => {
const user = requireUser(event);
const fd = await event.request.formData();
const name = ((fd.get('member') as string) || '').trim();
const fam = await pbAdmin.getOne('fams', user.famId);
const famSlug = fam?.slug || user.famId;
if (!name) {
return { success: true, famSlug, username: handleOf(user.username) };
}
const { otp, joinUrl } = await issueAccess({
famId: user.famId,
famSlug,
name
});
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) };
}
}; };
function requireUser(event: RequestEvent) {
if (!event.locals.user) throw redirect(303, '/signup');
return event.locals.user;
}
+176 -34
View File
@@ -1,48 +1,124 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import AuthShell from '$lib/components/AuthShell.svelte'; import AuthShell from '$lib/components/AuthShell.svelte';
import { slugify, handle } from '@shared/slugify';
let { form } = $props(); let { form } = $props();
let step = $state(1);
let famName = $state('');
let yourName = $state('');
let email = $state(''); let email = $state('');
let password = $state(''); let password = $state('');
let famName = $state(''); let childName = $state('');
let parentName = $state(''); let submitting = $state(false);
let localError = $state('');
let famSlugPreview = $derived(slugify(famName) || 'your-family');
let handlePreview = $derived(handle(yourName) || 'your-name');
const enhanceForm = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- canary $types lacks SubmitFunction
return () =>
async ({ update, result }: any) => {
submitting = true;
localError = '';
try {
await update();
} catch (e) {
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
}
submitting = false;
if (result.type !== 'failure' && result.type !== 'error') step++;
};
};
</script> </script>
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going."> <AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
{#if form?.error} {#if form?.message}
<p class="form-error">{form.error}</p> <p class="form-error">{form.message}</p>
{/if}
{#if localError}
<p class="form-error">{localError}</p>
{/if} {/if}
<form method="POST"> {#if step === 1}
<label> <form method="POST" action="?/signup" use:enhance={enhanceForm()}>
Family name <label>
<input name="famName" bind:value={famName} placeholder="The Smiths" required /> Family name
</label> <input name="familyName" bind:value={famName} placeholder="The Smiths" required />
<label> {#if famName}
Your name <span class="preview">Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code></span>
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required /> {/if}
</label> </label>
<label> <label>
Email Your name
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required /> <input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
</label> {#if yourName}
<label> <span class="preview">
Password Your address: <code>/</code><code class="inline-code">{famSlugPreview}/{handlePreview}</code>
<input <small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
type="password" </span>
name="password" {:else}
bind:value={password} <span class="preview-hint">No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span>
placeholder="8+ characters" {/if}
minlength={8} </label>
required <label>
/> Email
</label> <input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
<button type="submit">Create my family</button> </label>
</form> <label>
Password
<input
type="password"
name="password"
bind:value={password}
placeholder="8+ characters"
minlength={8}
required
/>
</label>
<button type="submit" disabled={submitting}>Create my family</button>
</form>
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
{/if}
<p class="alt"> {#if step === 2}
Already have a family? <a href="/login">Log in</a> <h3 class="step-title">Add a child now?</h3>
</p> <p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
<label>
Child's name
<input
type="text"
name="member"
bind:value={childName}
placeholder="Their first name"
/>
</label>
<button type="submit" disabled={submitting}>Create join code</button>
</form>
<p class="alt">
<a href="/{form?.famSlug}">Skip for now →</a>
</p>
{/if}
{#if step === 3}
<h3 class="step-title">{childName ? `Nice — share this code with ${childName}:` : 'Your family is ready!'}</h3>
{#if form?.code}
<div class="code">
<span class="code-text">{form.code}</span>
</div>
<p class="step-note">
They open <code class="inline-code">{form?.joinUrl}</code> and enter this code.
</p>
{:else}
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
{/if}
<div class="actions">
<a href="/{form?.famSlug}" class="btn-primary">Go to dashboard</a>
</div>
{/if}
</AuthShell> </AuthShell>
<style> <style>
@@ -81,6 +157,17 @@
cursor: pointer; cursor: pointer;
} }
button:hover { background: #3730a3; } button:hover { background: #3730a3; }
button:disabled { opacity: 0.6; cursor: default; }
.step-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
color: #111827;
}
.step-note {
margin: 0 0 1rem;
font-size: 0.85rem;
color: #6b7280;
}
.form-error { .form-error {
background: #fef2f2; background: #fef2f2;
color: #b91c1c; color: #b91c1c;
@@ -90,6 +177,23 @@
font-size: 0.85rem; font-size: 0.85rem;
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.preview {
font-size: 0.78rem;
color: #6b7280;
font-weight: 400;
}
.preview-hint {
font-size: 0.75rem;
color: #9ca3af;
font-weight: 400;
}
.preview .inline-code, .preview-hint .inline-code {
font-family: ui-monospace, monospace;
background: #f3f4f6;
border-radius: 4px;
padding: 0.05em 0.3em;
color: #374151;
}
.alt { .alt {
margin: 1.25rem 0 0; margin: 1.25rem 0 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -97,4 +201,42 @@
text-align: center; text-align: center;
} }
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; } .alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
</style> .code {
background: #eef2ff;
border: 1px dashed #a5b4fc;
border-radius: 10px;
padding: 1rem;
text-align: center;
margin: 0 0 0.75rem;
}
.code-text {
font-family: ui-monospace, monospace;
font-size: 1.6rem;
letter-spacing: 0.35em;
font-weight: 700;
color: #4338ca;
}
.inline-code {
font-family: ui-monospace, monospace;
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
}
.actions {
margin-top: 1.25rem;
}
.btn-primary {
display: block;
text-align: center;
background: #4338ca;
color: #fff;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
}
.btn-primary:hover { background: #3730a3; }
</style>
-159
View File
@@ -1,159 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import AuthShell from '$lib/layouts/AuthShell.svelte';
let { form }: { form: ActionData } = $props();
let step1 = $state({ familyName: '', email: '', password: '' });
let step2 = $state({ username: '' });
let step3 = $state({ child: '' });
let submitting = $state(false);
let step = $state(1);
const enhanceForm = () => {
return async ({ update, result }: { update: () => Promise<void> }) => {
submitting = true;
await update();
submitting = false;
if (result.type !== 'failure') {
step++;
}
};
};
</script>
<AuthShell title="Signup" subtitle="Create your family.">
{#if form?.message}
<h3 class="form-error">{form.message}</h3>
{/if}
{#if step === 1}
<form method="POST" action="?/signup" use:enhance={enhanceForm}>
<label>
Family Name
<input
type="text"
name="familyName"
bind:value={step1.familyName}
placeholder="Family Name"
required
/>
</label>
<label>
Email
<input
type="email"
name="email"
bind:value={step1.email}
placeholder="you@email.com"
required
/>
</label>
<label>
Password
<input type="password" name="password" bind:value={step1.password} required />
</label>
<button type="submit">Sign up</button>
</form>
<p class="alt">
Don't have a family yet? <a href="/signup">Create one</a>
</p>
{/if}
{#if step === 2}
<h3 class="form-error">Step 2</h3>
<p>Now for something more personal:</p>
<form method="POST" action="?/username" use:enhance={enhanceForm}>
<label
>Username
<input
type="text"
name="username"
bind:value={step2.username}
placeholder="Username"
required
/>
</label>
<button type="submit">Next</button>
</form>
{/if}
{#if step === 3}
<h3 class="form-error">Step 3</h3>
<p>Would you like to add a child device now?</p>
<form method="POST" action="?/child" use:enhance={enhanceForm}>
<label>
Add childs name:
<input type="text" name="member" bind:value={step3.child} />
</label>
<button type="submit">Next</button>
</form>
<p>Or skip straight to admin</p>
<a href="/admin">admin dashboard</a>
{/if}
{#if step === 4}
<h3 class="form-error">Step 4</h3>
<p>Nice - now share this device login code with {step3.child}:</p>
<div class="code">
<span class="code-text">{form.code}</span>
</div>
{/if}
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #3730a3;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
</style>
+5
View File
@@ -14,3 +14,8 @@ export const PB_ENDPOINT =
(SERVER_IP ? `http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`); (SERVER_IP ? `http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`);
export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev"; export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev";
export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123"; export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123";
// Shared secret used to DERIVE a child's users password as
// `MEMBER_SECRET + famSlug + username` (same formula as the frontend
// member-otp.ts). Never typed by anyone; OTP is the access gate.
export const MEMBER_SECRET =
process.env.MEMBER_SECRET || "famchamp-member-secret";
+190 -299
View File
@@ -1,9 +1,9 @@
import crypto from "node:crypto";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
import { pb } from "./pb.ts"; import { pb } from "./pb.ts";
import { migrate } from "./migrate.ts"; import { migrate } from "./migrate.ts";
import { PROXY_PORT } from "@shared/config.ts"; import { PROXY_PORT } from "@shared/config.ts";
import { PB_ENDPOINT, MEMBER_SECRET } from "./env.ts";
import { import {
weekStart as tzWeekStart, weekStart as tzWeekStart,
addDaysStr, addDaysStr,
@@ -12,7 +12,8 @@ import {
resolveTz, resolveTz,
nextPaydayAfter as nextPaydayAfterTz, nextPaydayAfter as nextPaydayAfterTz,
periodWindow, periodWindow,
} from "@shared/timezone.ts"; } from "@shared/timezone.ts";
import { slugify, handle, famUsername } from "@shared/slugify.ts";
const app = new Hono(); const app = new Hono();
@@ -124,10 +125,9 @@ async function getFamPaydayTime(famId: string): Promise<string> {
async function getFamTimezone(famId: string): Promise<string> { async function getFamTimezone(famId: string): Promise<string> {
try { try {
const fam = await pb.getList("fams", `id = '${famId}'`); const fam = await pb.getList("fams", `id = '${famId}'`);
const tz = fam.items?.[0]?.timezone; return resolveTz(fam.items?.[0]?.timezone);
return tz || "auto";
} catch { } catch {
return "auto"; return resolveTz("auto");
} }
} }
@@ -139,35 +139,42 @@ async function requireAdmin(c: any, next: any) {
if (!famId || !userId) { if (!famId || !userId) {
return c.json({ error: "Unauthorized" }, 401); return c.json({ error: "Unauthorized" }, 401);
} }
const admins = await pb.getList( const parents = await pb.getList(
"fam_admins", "users",
`famId = '${famId}' && userId = '${userId}'`, `famId = '${famId}' && role = 'parent' && id = '${userId}'`,
); );
if (!admins.items?.length) { if (!parents.items?.length) {
return c.json({ error: "Unauthorized" }, 401); return c.json({ error: "Unauthorized" }, 401);
} }
c.set("famId", famId); c.set("famId", famId);
return next(); return next();
} }
async function requireDeviceToken(c: any, next: any) { // Member (child) auth: validates the user's pb_token JWT and identifies the
const deviceToken = c.req.header("x-device-token"); // child. Children live in the `users` auth collection (role='child'); memberId
const famId = c.req.header("x-device-famid"); // (the child-scoped foreign key) is now the users record id. auth-refresh both
if (!famId || !deviceToken) { // cryptographically validates the token and returns the record in one call.
return c.json( async function requireMember(c: any, next: any) {
{ error: "x-device-token and x-device-famid headers required" }, const auth = c.req.header("Authorization") || "";
400, const token = auth.startsWith("Bearer ")
); ? auth.slice(7)
: c.req.header("x-pb-token");
if (!token) {
return c.json({ error: "Member auth required" }, 401);
} }
const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); const res = await fetch(`${PB_ENDPOINT}/api/collections/users/auth-refresh`, {
const members = await pb.getList( method: "POST",
"members", headers: { Authorization: `Bearer ${token}` },
`famId = '${famId}' && deviceToken = '${hashHex}'`, });
); if (!res.ok) return c.json({ error: "Unauthorized" }, 401);
const member = members.items?.[0]; const body = await res.json().catch(() => ({}));
if (!member) return c.json({ error: "Invalid device token" }, 401); const record = body?.record;
c.set("famId", famId); if (!record || record.role !== "child") {
c.set("memberId", member.id); return c.json({ error: "Forbidden" }, 403);
}
c.set("famId", record.famId);
c.set("memberId", record.id);
c.set("user", record);
return next(); return next();
} }
@@ -179,24 +186,16 @@ app.post("/api/admin/signup", async (c) => {
if (!email || !password || !famName) { if (!email || !password || !famName) {
return c.json({ error: "email, password, famName required" }, 400); return c.json({ error: "email, password, famName required" }, 400);
} }
const slug = famName const slug = slugify(famName);
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase();
const user = await pb.createUser(email, password); const user = await pb.createUser(email, password);
const fam = await pb.create("fams", { const fam = await pb.create("fams", {
name: famName, name: famName,
slug, slug,
inviteCode,
featureFlags: {}, featureFlags: {},
timezone: "auto", timezone: "auto",
}); });
const adminName = parentName || email.split("@")[0]; const adminName = parentName || email.split("@")[0];
const admin = await pb.create("fam_admins", { await pb.update("users", user.id, {
famId: fam.id,
userId: user.id,
email,
name: adminName, name: adminName,
color: "#6366f1", color: "#6366f1",
}); });
@@ -207,9 +206,9 @@ app.post("/api/admin/signup", async (c) => {
famSlug: slug, famSlug: slug,
userId: user.id, userId: user.id,
token: authResult.token, token: authResult.token,
memberId: admin.id, memberId: user.id,
memberName: admin.name, memberName: adminName,
memberColor: admin.color, memberColor: "#6366f1",
role: "parent", role: "parent",
}); });
} catch (err) { } catch (err) {
@@ -224,28 +223,30 @@ app.post("/api/admin/login", async (c) => {
return c.json({ error: "email, password required" }, 400); return c.json({ error: "email, password required" }, 400);
const authResult = await pb.authWithPassword(email, password); const authResult = await pb.authWithPassword(email, password);
const userId = authResult.record.id; const userId = authResult.record.id;
const admins = await pb.getList("fam_admins", `userId = '${userId}'`); const parents = await pb.getList(
const admin = admins.items?.[0]; "users",
if (!admin) return c.json({ error: "No fam found for user" }, 404); `famId != '' && role = 'parent' && id = '${userId}'`,
const fams = await pb.getList("fams", `id = '${admin.famId}'`); );
const parent = parents.items?.[0];
if (!parent) return c.json({ error: "No fam found for user" }, 404);
const fams = await pb.getList("fams", `id = '${parent.famId}'`);
const fam = fams.items?.[0]; const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Fam not found" }, 404); if (!fam) return c.json({ error: "Fam not found" }, 404);
// Update fam_admins name/color on login
const defaultName = email.split("@")[0]; const defaultName = email.split("@")[0];
const adminPatch: Record<string, string> = { const parentPatch: Record<string, string> = {
name: defaultName, name: defaultName,
color: "#6366f1", color: "#6366f1",
}; };
if (!admin.email) adminPatch.email = email; if (!parent.email) parentPatch.email = email;
const updatedAdmin = await pb.update("fam_admins", admin.id, adminPatch); await pb.update("users", parent.id, parentPatch);
return c.json({ return c.json({
famId: fam.id, famId: fam.id,
famSlug: fam.slug, famSlug: fam.slug,
userId, userId,
token: authResult.token, token: authResult.token,
memberId: updatedAdmin.id, memberId: parent.id,
memberName: updatedAdmin.name, memberName: defaultName,
memberColor: updatedAdmin.color, memberColor: "#6366f1",
role: "parent", role: "parent",
}); });
} catch (err) { } catch (err) {
@@ -253,124 +254,7 @@ app.post("/api/admin/login", async (c) => {
} }
}); });
async function joinMemberFlow( app.patch("/api/members/me", requireMember, async (c) => {
famId: string,
name: string,
deviceToken: string,
) {
const existing = await pb.getList(
"members",
`famId = '${famId}' && name = '${name}'`,
);
const slot = existing.items?.[0];
if (!slot) throw new Error(`No member named "${name}" in this family`);
const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex");
const tokenHint = deviceToken.substring(0, 8);
await pb.update("members", slot.id, {
deviceToken: hashHex,
deviceTokenHint: tokenHint,
});
const newCode = Math.random().toString(36).substring(2, 8).toUpperCase();
await pb.update("fams", famId, { inviteCode: newCode });
return {
memberId: slot.id,
deviceToken,
name: slot.name,
inviteCode: newCode,
};
}
app.post("/api/members/join", async (c) => {
try {
const { inviteCode, name, deviceToken } = await c.req.json();
if (!inviteCode || !name || !deviceToken)
return c.json({ error: "inviteCode, name, deviceToken required" }, 400);
const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Invalid invite code" }, 404);
const result = await joinMemberFlow(fam.id, name, deviceToken);
return c.json({ famId: fam.id, famSlug: fam.slug, ...result });
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/members/direct-join", async (c) => {
try {
const { inviteCode, name } = await c.req.json();
if (!inviteCode || !name)
return c.json({ error: "inviteCode, name required" }, 400);
const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Invalid invite code" }, 404);
const deviceToken = crypto.randomUUID();
const result = await joinMemberFlow(fam.id, name, deviceToken);
return c.json({ famId: fam.id, famSlug: fam.slug, ...result });
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/members/verify", async (c) => {
try {
const { famId, deviceToken } = await c.req.json();
if (!famId || !deviceToken)
return c.json({ error: "famId, deviceToken required" }, 400);
const hashHex = crypto
.createHash("sha256")
.update(deviceToken)
.digest("hex");
const members = await pb.getList(
"members",
`famId = '${famId}' && deviceToken = '${hashHex}'`,
);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
return c.json({
famId: member.famId,
memberId: member.id,
name: member.name,
});
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/members/verify-token", async (c) => {
try {
const { deviceToken, famSlug } = await c.req.json();
if (!deviceToken || !famSlug)
return c.json({ error: "deviceToken, famSlug required" }, 400);
const hashHex = crypto
.createHash("sha256")
.update(deviceToken)
.digest("hex");
// Look the member up by its (unique) device token hash, then confirm the
// requested slug belongs to that member's own fam. Looking up by slug first
// is unsafe because slug isn't unique — duplicate fams (e.g. after dev↔prod
// store drift) would resolve to the wrong family and reject valid tokens.
const members = await pb.getList(
"members",
`deviceToken = '${hashHex}'`,
);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
const fams = await pb.getList("fams", `id = '${member.famId}'`);
const fam = fams.items?.[0];
if (!fam || fam.slug !== famSlug)
return c.json({ error: "Invalid device token" }, 401);
return c.json({
famId: member.famId,
memberId: member.id,
name: member.name,
color: member.color,
});
} catch (err) {
return handleError(c, err);
}
});
app.patch("/api/members/me", requireDeviceToken, async (c) => {
try { try {
const body = await c.req.json(); const body = await c.req.json();
const memberId = c.get("memberId"); const memberId = c.get("memberId");
@@ -379,7 +263,7 @@ app.patch("/api/members/me", requireDeviceToken, async (c) => {
if (body.color) update.color = body.color; if (body.color) update.color = body.color;
if (!Object.keys(update).length) if (!Object.keys(update).length)
return c.json({ error: "Nothing to update" }, 400); return c.json({ error: "Nothing to update" }, 400);
const record = await pb.update("members", memberId, update); const record = await pb.update("users", memberId, update);
return c.json({ id: record.id, name: record.name, color: record.color }); return c.json({ id: record.id, name: record.name, color: record.color });
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
@@ -435,7 +319,10 @@ app.delete("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => {
app.get("/api/admin/:famId/members", requireAdmin, async (c) => { app.get("/api/admin/:famId/members", requireAdmin, async (c) => {
try { try {
const { famId } = c.req.param(); const { famId } = c.req.param();
const data = await pb.getList("members", `famId = '${famId}'`); const data = await pb.getList(
"users",
`famId = '${famId}' && role = 'child'`,
);
return c.json(data.items); return c.json(data.items);
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
@@ -446,7 +333,22 @@ app.post("/api/admin/:famId/members", requireAdmin, async (c) => {
try { try {
const { famId } = c.req.param(); const { famId } = c.req.param();
const body = await c.req.json(); const body = await c.req.json();
const record = await pb.create("members", { famId, ...body }); const fam = (await pb.getList("fams", `id = '${famId}'`)).items?.[0];
if (!fam) return c.json({ error: "Fam not found" }, 404);
const handleName = handle(body.name || body.username || "");
if (!handleName) return c.json({ error: "username required" }, 400);
const username = famUsername(fam.slug, handleName);
const password = `${MEMBER_SECRET}${fam.slug}${handleName}`;
const record = await pb.create("users", {
famId,
role: "child",
username,
name: body.name || handleName,
color: body.color || "#6366f1",
emailVisibility: false,
password,
passwordConfirm: password,
});
return c.json(record); return c.json(record);
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
@@ -457,7 +359,7 @@ app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
try { try {
const { famId, id } = c.req.param(); const { famId, id } = c.req.param();
const body = await c.req.json(); const body = await c.req.json();
const record = await pb.update("members", id, body); const record = await pb.update("users", id, body);
return c.json(record); return c.json(record);
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
@@ -467,7 +369,7 @@ app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
app.delete("/api/admin/:famId/members/:id", requireAdmin, async (c) => { app.delete("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
try { try {
const { id } = c.req.param(); const { id } = c.req.param();
await pb.delete("members", id); await pb.delete("users", id);
return c.json({ ok: true }); return c.json({ ok: true });
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
@@ -592,7 +494,6 @@ app.get("/api/admin/:famId/fam", requireAdmin, async (c) => {
return c.json({ return c.json({
name: fam.name, name: fam.name,
slug: fam.slug, slug: fam.slug,
inviteCode: fam.inviteCode,
payday: fam.payday, payday: fam.payday,
paydayTime: fam.paydayTime || "18:00", paydayTime: fam.paydayTime || "18:00",
timezone: fam.timezone || "auto", timezone: fam.timezone || "auto",
@@ -609,10 +510,7 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
if (body.name !== undefined) { if (body.name !== undefined) {
const name = body.name; const name = body.name;
if (!name) return c.json({ error: "name required" }, 400); if (!name) return c.json({ error: "name required" }, 400);
const slug = name const slug = slugify(name);
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const record = await pb.update("fams", famId, { name, slug }); const record = await pb.update("fams", famId, { name, slug });
return c.json({ return c.json({
name: record.name, name: record.name,
@@ -667,20 +565,6 @@ app.get("/api/admin/:famId/verify", requireAdmin, async (c) => {
return c.json({ verified: true }); return c.json({ verified: true });
}); });
app.post("/api/admin/:famId/regen-invite", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase();
const fams = await pb.getList("fams", `id = '${famId}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Fam not found" }, 404);
await pb.update("fams", famId, { inviteCode });
return c.json({ inviteCode });
} catch (err) {
return handleError(c, err);
}
});
// ── Admin: Weekly Summary ────────────────────────────── // ── Admin: Weekly Summary ──────────────────────────────
app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => { app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
@@ -690,7 +574,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
const tz = await getFamTimezone(famId); const tz = await getFamTimezone(famId);
const ws = weekStart(payday, tz); const ws = weekStart(payday, tz);
const [members, assigned, completions] = await Promise.all([ const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`), pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`), pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
]); ]);
@@ -854,7 +738,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
const [members, assigned, completions, configs, rewards] = const [members, assigned, completions, configs, rewards] =
await Promise.all([ await Promise.all([
pb.getList("members", `famId = '${famId}'`), pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`), pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
pb pb
@@ -1183,7 +1067,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
); );
} catch {} } catch {}
const [members, assigned, completions] = await Promise.all([ const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`), pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`), pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}'`), pb.getList("completions", `famId = '${famId}'`),
]); ]);
@@ -1310,7 +1194,7 @@ async function evaluateFam(famId: string): Promise<void> {
if (!configs.length) return; if (!configs.length) return;
const [allMembers, allAssigned, allCompletions] = await Promise.all([ const [allMembers, allAssigned, allCompletions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`), pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`), pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}'`), pb.getList("completions", `famId = '${famId}'`),
]); ]);
@@ -1581,7 +1465,10 @@ app.post(
`famId = '${famId}' && bonusConfigId = '${cfg.id}'`, `famId = '${famId}' && bonusConfigId = '${cfg.id}'`,
); );
const members = await pb.getList("members", `famId = '${famId}'`); const members = await pb.getList(
"users",
`famId = '${famId}' && role = 'child'`,
);
const targetMembers: any[] = []; const targetMembers: any[] = [];
if (cfg.target === "competitive" || cfg.target === "collaborative") { if (cfg.target === "competitive" || cfg.target === "collaborative") {
@@ -1672,7 +1559,7 @@ app.post(
// ── Member: Completion Toggle ──────────────────────────── // ── Member: Completion Toggle ────────────────────────────
app.post("/api/completions/toggle", requireDeviceToken, async (c) => { app.post("/api/completions/toggle", requireMember, async (c) => {
try { try {
const famId = c.get("famId"); const famId = c.get("famId");
const memberId = c.get("memberId"); const memberId = c.get("memberId");
@@ -1737,19 +1624,11 @@ app.post(
// ── Member: Get seasons ───────────────────────────────── // ── Member: Get seasons ─────────────────────────────────
app.get("/api/members/seasons", async (c) => { app.get("/api/members/seasons", requireMember, async (c) => {
try { try {
const deviceToken = c.req.header("x-device-token"); const famId = c.get("famId");
if (!deviceToken) return c.json({ error: "x-device-token required" }, 400); const seasons = await pb.getList("seasons", `famId = '${famId}'`);
const hashHex = crypto return c.json({ seasons: seasons.items, famId });
.createHash("sha256")
.update(deviceToken)
.digest("hex");
const members = await pb.getList("members", `deviceToken = '${hashHex}'`);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
const seasons = await pb.getList("seasons", `famId = '${member.famId}'`);
return c.json({ seasons: seasons.items, famId: member.famId });
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
} }
@@ -1757,7 +1636,7 @@ app.get("/api/members/seasons", async (c) => {
// ── Member: Get chores (for kanban) ────────────────────── // ── Member: Get chores (for kanban) ──────────────────────
app.post("/api/members/my-chores", requireDeviceToken, async (c) => { app.post("/api/members/my-chores", requireMember, async (c) => {
try { try {
const famId = c.get("famId"); const famId = c.get("famId");
const memberId = c.get("memberId"); const memberId = c.get("memberId");
@@ -1926,56 +1805,56 @@ app.post(
); );
app.get("/api/admin/:famId/profile", requireAdmin, async (c) => { app.get("/api/admin/:famId/profile", requireAdmin, async (c) => {
try { try {
const { famId } = c.req.param(); const { famId } = c.req.param();
const userId = c.req.header("x-session-userid"); const userId = c.req.header("x-session-userid");
const admins = await pb.getList( const parents = await pb.getList(
"fam_admins", "users",
`famId = '${famId}' && userId = '${userId}'`, `famId = '${famId}' && role = 'parent' && id = '${userId}'`,
); );
const admin = admins.items?.[0]; const parent = parents.items?.[0];
if (!admin) return c.json({ error: "Admin not found" }, 404); if (!parent) return c.json({ error: "Admin not found" }, 404);
return c.json({ return c.json({
id: admin.id, id: parent.id,
name: admin.name, name: parent.name || "",
color: admin.color, color: parent.color || "#6366f1",
email: admin.email || "", email: parent.email || "",
}); });
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
} }
}); });
app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => { app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => {
try { try {
const { famId } = c.req.param(); const { famId } = c.req.param();
const userId = c.req.header("x-session-userid"); const userId = c.req.header("x-session-userid");
const body = await c.req.json(); const body = await c.req.json();
const admins = await pb.getList( const parents = await pb.getList(
"fam_admins", "users",
`famId = '${famId}' && userId = '${userId}'`, `famId = '${famId}' && role = 'parent' && id = '${userId}'`,
); );
const admin = admins.items?.[0]; const parent = parents.items?.[0];
if (!admin) return c.json({ error: "Admin not found" }, 404); if (!parent) return c.json({ error: "Admin not found" }, 404);
const update: Record<string, string> = {}; const update: Record<string, string> = {};
if (body.name) update.name = body.name; if (body.name) update.name = body.name;
if (body.color) update.color = body.color; if (body.color) update.color = body.color;
if (body.email !== undefined) update.email = body.email; if (body.email !== undefined) update.email = body.email;
const record = await pb.update("fam_admins", admin.id, update); const record = await pb.update("users", parent.id, update);
return c.json({ return c.json({
id: record.id, id: record.id,
name: record.name, name: record.name || "",
color: record.color, color: record.color || "#6366f1",
email: record.email || "", email: record.email || "",
}); });
} catch (err) { } catch (err) {
return handleError(c, err); return handleError(c, err);
} }
}); });
// ── Member: Claim a reward ───────────────────────────── // ── Member: Claim a reward ─────────────────────────────
app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => { app.post("/api/members/rewards/:id/claim", requireMember, async (c) => {
try { try {
const { id } = c.req.param(); const { id } = c.req.param();
const famId = c.get("famId"); const famId = c.get("famId");
@@ -2008,7 +1887,7 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
// ── Member: Request all unclaimed rewards ──────────────── // ── Member: Request all unclaimed rewards ────────────────
app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => { app.post("/api/members/rewards/request-all", requireMember, async (c) => {
try { try {
const famId = c.get("famId"); const famId = c.get("famId");
const memberId = c.get("memberId"); const memberId = c.get("memberId");
@@ -2064,7 +1943,10 @@ async function releaseWeek(famId: string) {
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday }; if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
const members = await pb.getList("members", `famId = '${famId}'`); const members = await pb.getList(
"users",
`famId = '${famId}' && role = 'child'`,
);
let cashRewards: any = { items: [] }; let cashRewards: any = { items: [] };
try { try {
cashRewards = await pb.getList( cashRewards = await pb.getList(
@@ -2118,21 +2000,26 @@ async function authorizeFamReq(c: any): Promise<string | null> {
const sessFam = c.req.header("x-session-famid"); const sessFam = c.req.header("x-session-famid");
const sessUser = c.req.header("x-session-userid"); const sessUser = c.req.header("x-session-userid");
if (sessFam && sessUser) { if (sessFam && sessUser) {
const admins = await pb.getList( const parents = await pb.getList(
"fam_admins", "users",
`famId = '${sessFam}' && userId = '${sessUser}'`, `famId = '${sessFam}' && role = 'parent' && id = '${sessUser}'`,
); );
if (admins.items?.length) return sessFam; if (parents.items?.length) return sessFam;
} }
const devToken = c.req.header("x-device-token"); const auth = c.req.header("Authorization") || "";
const devFam = c.req.header("x-device-famid"); const token = auth.startsWith("Bearer ")
if (devFam && devToken) { ? auth.slice(7)
const hashHex = crypto.createHash("sha256").update(devToken).digest("hex"); : c.req.header("x-pb-token");
const members = await pb.getList( if (token) {
"members", const res = await fetch(
`famId = '${devFam}' && deviceToken = '${hashHex}'`, `${PB_ENDPOINT}/api/collections/users/auth-refresh`,
{ method: "POST", headers: { Authorization: `Bearer ${token}` } },
); );
if (members.items?.length) return devFam; if (res.ok) {
const body = await res.json().catch(() => ({}));
const record = body?.record;
if (record?.famId) return record.famId;
}
} }
return null; return null;
} }
@@ -2162,7 +2049,7 @@ app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
// Fetch data for summary // Fetch data for summary
const [members, assigned, completions] = await Promise.all([ const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`), pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`), pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
]); ]);
@@ -2252,7 +2139,7 @@ app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => {
const days = body.days || 7; const days = body.days || 7;
const [members, templates] = await Promise.all([ const [members, templates] = await Promise.all([
pb.getList("members", `famId = '${famId}'`), pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("chore_templates", `famId = '${famId}'`), pb.getList("chore_templates", `famId = '${famId}'`),
]); ]);
@@ -2329,42 +2216,46 @@ async function resolveChatActor(c: any) {
const hsFamId = c.req.header("x-session-famid"); const hsFamId = c.req.header("x-session-famid");
const hsUserId = c.req.header("x-session-userid"); const hsUserId = c.req.header("x-session-userid");
if (hsFamId && hsUserId) { if (hsFamId && hsUserId) {
const admins = await pb.getList( const parents = await pb.getList(
"fam_admins", "users",
`famId = '${hsFamId}' && userId = '${hsUserId}'`, `famId = '${hsFamId}' && role = 'parent' && id = '${hsUserId}'`,
); );
const admin = admins.items?.[0]; const parent = parents.items?.[0];
if (admin) { if (parent) {
return { return {
famId: hsFamId, famId: hsFamId,
actor: { actor: {
id: admin.id, id: parent.id,
type: "admin", type: "admin",
name: admin.name, name: parent.name || "",
color: admin.color, color: parent.color || "#6366f1",
}, },
}; };
} }
} }
const famId = c.req.header("x-device-famid"); const auth = c.req.header("Authorization") || "";
const deviceToken = c.req.header("x-device-token"); const token = auth.startsWith("Bearer ")
if (famId && deviceToken) { ? auth.slice(7)
const hash = crypto.createHash("sha256").update(deviceToken).digest("hex"); : c.req.header("x-pb-token");
const members = await pb.getList( if (token) {
"members", const res = await fetch(
`famId = '${famId}' && deviceToken = '${hash}'`, `${PB_ENDPOINT}/api/collections/users/auth-refresh`,
{ method: "POST", headers: { Authorization: `Bearer ${token}` } },
); );
const member = members.items?.[0]; if (res.ok) {
if (member) { const body = await res.json().catch(() => ({}));
return { const user = body?.record;
famId, if (user?.famId) {
actor: { return {
id: member.id, famId: user.famId,
type: "member", actor: {
name: member.name, id: user.id,
color: member.color, type: user.role === "child" ? "member" : "admin",
}, name: user.name || user.username || "",
}; color: user.color || "",
},
};
}
} }
} }
return null; return null;
+567
View File
@@ -75,6 +75,10 @@ async function ensureSchema(): Promise<void> {
console.log("[migrate] Bootstrapping base schema on fresh PocketBase..."); console.log("[migrate] Bootstrapping base schema on fresh PocketBase...");
const ids: Record<string, string> = {}; const ids: Record<string, string> = {};
// `users` is PocketBase's native auth collection (created on first boot),
// not part of SCHEMA_PLAN. Pre-register its id so relations can point at it.
const nativeUsers = await getCollection("users");
if (nativeUsers) ids.users = nativeUsers.id;
for (const entry of SCHEMA_PLAN) { for (const entry of SCHEMA_PLAN) {
const createdId = await createCollection(entry.build(ids)); const createdId = await createCollection(entry.build(ids));
if (createdId) ids[entry.name] = createdId; if (createdId) ids[entry.name] = createdId;
@@ -1520,5 +1524,568 @@ export async function migrate(): Promise<void> {
console.log(` ↳ fams collection not found — chat collections deferred`); console.log(` ↳ fams collection not found — chat collections deferred`);
} }
// ── 26. Add famId + role to the users auth collection ──
// Admin identity now lives on the auth record so PB rules can scope via
// @request.auth.famId. role defaults to "parent" (only admins log in for now;
// children become a separate auth collection in the membership phase).
{
const usersCol = await getCollection("users");
if (usersCol) {
const famsCol2 = await getCollection("fams");
const hasFamId = usersCol.fields.some((f: any) => f.name === "famId");
const hasRole = usersCol.fields.some((f: any) => f.name === "role");
if (!hasFamId || !hasRole) {
console.log("[migrate] Adding famId/role to users collection...");
if (!hasFamId && famsCol2) {
usersCol.fields.push({
name: "famId",
type: "relation",
required: false,
collectionId: famsCol2.id,
maxSelect: 1,
cascadeDelete: false,
});
}
if (!hasRole) {
usersCol.fields.push({
name: "role",
type: "select",
required: false,
values: ["parent", "child"],
maxSelect: 1,
});
}
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule: usersCol.listRule,
viewRule: usersCol.viewRule,
createRule: usersCol.createRule,
updateRule: usersCol.updateRule,
deleteRule: usersCol.deleteRule,
fields: usersCol.fields,
});
console.log(" ✓ users.famId/role added");
} else {
console.log(" ↳ users.famId/role already present");
}
}
}
// ── 27. Backfill users.famId from fam_admins ──
{
const usersCol = await getCollection("users");
if (usersCol) {
const hasFamId = usersCol.fields.some((f: any) => f.name === "famId");
if (hasFamId) {
try {
const t = await auth();
const adminsRes = await fetch(
`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const adminsData = await adminsRes.json();
let count = 0;
for (const a of adminsData?.items || []) {
const u = await fetch(
`${PB_ENDPOINT}/api/collections/users/records/${a.userId}`,
{ headers: { Authorization: `Bearer ${t}` } },
);
if (!u.ok) continue;
const user = await u.json();
if (!user.famId) {
await fetch(
`${PB_ENDPOINT}/api/collections/users/records/${user.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ famId: a.famId, role: "parent" }),
},
);
count++;
}
}
if (count > 0)
console.log(` ✓ Backfilled users.famId/role for ${count} users`);
else console.log(" ↳ users.famId already backfilled");
} catch (e) {
console.log(
" ↳ users.famId backfill skipped:",
e instanceof Error ? e.message : e,
);
}
}
}
}
// ── 28. Lock family-scoped WRITE rules to the caller's famId ──
// Replaces the old "superuser-only writes via Hono" model. Once SvelteKit
// writes as the authenticated user, PB itself enforces famId scoping — no
// more internet CRUD (anonymous `@request.auth` is null → rule fails).
// Reads stay public until children become authenticated (membership phase).
{
const WRITE_RULE = "@request.body.famId = @request.auth.famId";
const SCOPED_RULE = "famId = @request.auth.famId";
const WRITE_SCOPED_COLLECTIONS = [
"members",
"chore_templates",
"assigned_chores",
"completions",
"bonus_configs",
"bonus_templates",
"rewards",
"seasons",
"settings",
"weekly_history",
"monthly_bonuses",
"messages",
"chat_typing",
];
for (const name of WRITE_SCOPED_COLLECTIONS) {
const c = await getCollection(name);
if (!c) continue;
if (
c.createRule === WRITE_RULE &&
c.updateRule === SCOPED_RULE &&
c.deleteRule === SCOPED_RULE
) {
continue;
}
await updateCollection(c.id, {
name,
type: c.type,
listRule: c.listRule,
viewRule: c.viewRule,
createRule: WRITE_RULE,
updateRule: SCOPED_RULE,
deleteRule: SCOPED_RULE,
fields: c.fields,
});
console.log(` ↳ Locked ${name} write rules to famId scoping`);
}
}
// ── 28b. Allow each admin to UPDATE their own fam record ──
// fams is the root collection: its record id IS the famId, and it has no
// famId field pointing to itself. So the scoping rule compares the record id
// to the caller's famId. Reads + create/delete stay superuser-only.
{
const c = await getCollection("fams");
if (c && c.updateRule !== "id = @request.auth.famId") {
await updateCollection(c.id, {
name: "fams",
type: c.type,
listRule: c.listRule,
viewRule: c.viewRule,
createRule: c.createRule,
updateRule: "id = @request.auth.famId",
deleteRule: c.deleteRule,
fields: c.fields,
});
console.log(" ↳ fams.updateRule scoped to own record (id = @request.auth.famId)");
}
}
// ── 29. Add username identity to the users auth collection ──
// Members now live in `users` alongside admins. They never type a password;
// OTP is the gate. username is registered as a password-auth IDENTITY so the
// server can authWithPassword(username, derivedPassword) at join time — the
// password is derived from (MEMBER_SECRET + famSlug + username), never
// user-supplied. email is made optional (admins log in via email; members use
// username only and have no email). In PB v0.23+ an identity field must have
// a single-column UNIQUE index AND be listed in passwordAuth.identityFields.
{
const usersCol = await getCollection("users");
if (usersCol) {
const famsCol2 = await getCollection("fams");
const hasUsername = usersCol.fields.some((f: any) => f.name === "username");
// email: required → optional (members have no email)
const emailField = usersCol.fields.find((f: any) => f.name === "email");
if (emailField && emailField.required) {
emailField.required = false;
}
if (!hasUsername && famsCol2) {
usersCol.fields.push({ name: "username", type: "text", required: true });
}
// ensure a UNIQUE index on username exists (identity requirement)
let indexes = usersCol.indexes || [];
const hasUsernameIdx = indexes.some((i: string) => /username/i.test(i));
if (!hasUsernameIdx) {
indexes = [
...indexes,
"CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''",
];
}
// register username as a password-auth identity field
const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] };
const identityFields = Array.isArray(pwAuth.identityFields)
? pwAuth.identityFields
: ["email"];
if (!identityFields.includes("username")) identityFields.push("username");
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule: usersCol.listRule,
viewRule: usersCol.viewRule,
createRule: usersCol.createRule,
updateRule: usersCol.updateRule,
deleteRule: usersCol.deleteRule,
fields: usersCol.fields,
indexes,
passwordAuth: { enabled: true, identityFields },
});
console.log(" ✓ users: email optional, username auth identity");
}
}
// ── 30. user_configs: identity + OTP store (superuser-only) ──
// Holds the rotating one-time code, colour, and the OTP-issue timestamp used
// for the 20-minute window. Sensitive (OTPs) → not public; read/written via
// createSuperClient in SvelteKit. `created`/`updated` are PB built-ins; the
// manual `updatedAt` is written ONLY on OTP (re)issue so the window stays
// accurate (colour edits must not bump it). userId links to the users auth
// record so each child's config is uniquely addressable.
{
if (!(await getCollection("user_configs"))) {
const famsCol = await getCollection("fams");
const usersCol = await getCollection("users");
if (!famsCol || !usersCol) throw new Error("fams/users collection not found");
console.log("[migrate] Creating user_configs collection...");
await createCollection({
name: "user_configs",
type: "base",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "famId",
type: "relation",
required: true,
collectionId: famsCol.id,
maxSelect: 1,
cascadeDelete: false,
},
{
name: "userId",
type: "relation",
required: true,
collectionId: usersCol.id,
maxSelect: 1,
cascadeDelete: false,
},
{ name: "otp", type: "text", required: false },
{ name: "colour", type: "text", required: false },
{ name: "updatedAt", type: "text", required: false },
],
});
} else {
console.log(" ↳ user_configs already exists");
}
}
// ── 31. Add name + color to users (child display fields) ──
// Children are now `users` records; admin views (weekly summary, ledger,
// bonus progress, kanban) render name/color from the users record directly
// instead of a separate members row.
{
const usersCol = await getCollection("users");
if (usersCol) {
const needName = !usersCol.fields.some((f: any) => f.name === "name");
const needColor = !usersCol.fields.some((f: any) => f.name === "color");
if (needName || needColor) {
console.log("[migrate] Adding name/color to users collection...");
if (needName)
usersCol.fields.push({ name: "name", type: "text", required: false });
if (needColor)
usersCol.fields.push({ name: "color", type: "text", required: false });
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule: usersCol.listRule,
viewRule: usersCol.viewRule,
createRule: usersCol.createRule,
updateRule: usersCol.updateRule,
deleteRule: usersCol.deleteRule,
fields: usersCol.fields,
});
console.log(" ✓ users.name/color added");
// Backfill display fields for existing child users (created before the
// name/color fields existed).
try {
const t = await auth();
const childRes = await fetch(
`${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent(
"role = 'child'",
)}`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const childData = await childRes.json();
for (const u of childData?.items || []) {
if (!u.name || !u.color) {
await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({
name: u.name || u.username || "",
color: u.color || "#6366f1",
}),
});
}
}
if ((childData?.items || []).length)
console.log(" ↳ Backfilled child users name/color");
} catch (e) {
console.log(
" ↳ child name/color backfill skipped:",
e instanceof Error ? e.message : e,
);
}
} else {
console.log(" ↳ users.name/color already present");
}
}
}
// ── 31b. Backfill parent role on users ──
// Legacy parent users were created before users.role existed (or before it
// was set), leaving role empty. The app falls back `role || 'parent'`, but we
// normalize it here so records are self-consistent.
{
const t = await auth();
const headers = {
Authorization: `Bearer ${t}`,
"Content-Type": "application/json",
};
try {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent(
"role = ''",
)}`,
{ headers },
);
const data = await res.json();
for (const u of data?.items || []) {
await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ role: "parent" }),
});
}
if ((data?.items || []).length)
console.log(` ↳ Backfilled ${data.items.length} users -> role=parent`);
} catch (e) {
console.log(
" ↳ parent role backfill skipped:",
e instanceof Error ? e.message : e,
);
}
}
// ── 32. Repoint memberId relations from members -> users ──
// Every child-scoped collection's memberId field now points at the users
// auth collection (children live there as role='child'). Must run before
// the members collection is deleted (a collection referenced by a relation
// field cannot be removed). PB (v0.39) forbids changing a relation field's
// target collection in place, so we drop the field and re-add it targeting
// users in two separate collection updates.
{
const usersCol = await getCollection("users");
const membersCol = await getCollection("members");
if (usersCol && membersCol) {
for (const name of [
"assigned_chores",
"completions",
"rewards",
"weekly_history",
"bonus_configs",
]) {
const c = await getCollection(name);
if (!c) continue;
const f = c.fields.find((x: any) => x.name === "memberId");
if (f && f.collectionId === membersCol.id) {
const base = {
name,
type: c.type,
listRule: c.listRule,
viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
};
const without = c.fields.filter((x: any) => x.name !== "memberId");
// 1) drop memberId
await updateCollection(c.id, { ...base, fields: without });
// 2) re-add memberId pointing at users
const withUsers = without.concat({
name: "memberId",
type: "relation",
required: false,
collectionId: usersCol.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
});
await updateCollection(c.id, { ...base, fields: withUsers });
console.log(` ↳ Repointed ${name}.memberId -> users`);
} else {
console.log(` ↳ ${name}.memberId already -> users`);
}
}
} else if (usersCol) {
console.log(" ↳ members already gone; memberId rels unchanged");
}
}
// ── 32b. Scope users read/write rules for the child model ──
// Children live in `users`. Family reads (admin famStore, kanban, loads) run
// as the authenticated user via pbUser/pb.authStore, so the collection needs
// list/view rules keyed to the caller's famId. Creating children stays
// superuser-only (issueAccess/createChild use createSuperClient); parents may
// update/delete their own fam's child users via pbUser.
{
const usersCol = await getCollection("users");
if (usersCol) {
const listRule = "famId = @request.auth.famId";
const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'";
if (
usersCol.listRule !== listRule ||
usersCol.viewRule !== listRule ||
usersCol.updateRule !== parentWrite ||
usersCol.deleteRule !== parentWrite
) {
console.log("[migrate] Scoping users read/write rules...");
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule,
viewRule: listRule,
createRule: usersCol.createRule,
updateRule: parentWrite,
deleteRule: parentWrite,
fields: usersCol.fields,
});
console.log(" ↳ users rules: list/view = famId scope, parent write");
} else {
console.log(" ↳ users rules already scoped");
}
}
}
// ── 33. Delete legacy members collection + stale child-scoped data ──
// Old member rows and the data keyed to them are not preserved (accounts
// won't be reused). Wipe child-scoped rows whose memberId pointed at the old
// members rows, clear bonus_configs member targets, then drop the collection.
{
const membersCol = await getCollection("members");
if (membersCol) {
console.log("[migrate] Removing legacy members collection + stale data...");
const t = await auth();
const headers = { Authorization: `Bearer ${t}` };
const wipeCollection = async (name: string) => {
let page = 0;
for (;;) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/${name}/records?perPage=100&page=${page + 1}`,
{ headers },
);
const data = await res.json();
const items: any[] = data?.items || [];
if (!items.length) break;
for (const r of items) {
await fetch(
`${PB_ENDPOINT}/api/collections/${name}/records/${r.id}`,
{ method: "DELETE", headers },
);
}
if (items.length < 100) break;
page++;
}
};
for (const name of [
"assigned_chores",
"completions",
"rewards",
"weekly_history",
]) {
await wipeCollection(name);
}
console.log(" ↳ Wiped stale child-scoped data");
const cfgRes = await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=200`,
{ headers },
);
const cfgData = await cfgRes.json();
for (const cfg of cfgData?.items || []) {
if (cfg.memberId) {
await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records/${cfg.id}`,
{
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ memberId: null }),
},
);
}
}
console.log(" ↳ Cleared bonus_configs.memberId targets");
await fetch(`${PB_ENDPOINT}/api/collections/${membersCol.id}`, {
method: "DELETE",
headers,
});
console.log(" ✓ members collection deleted");
} else {
console.log(" ↳ members already removed");
}
}
// ── 34. Drop legacy fam_admins collection + unused fams.inviteCode ──
// Parent identity now lives entirely on the `users` record (famId, role,
// name, color, email); the join flow is OTP-based, so inviteCode is unused.
{
const adminsCol = await getCollection("fam_admins");
if (adminsCol) {
const t = await auth();
const headers = { Authorization: `Bearer ${t}` };
await fetch(`${PB_ENDPOINT}/api/collections/${adminsCol.id}`, {
method: "DELETE",
headers,
});
console.log(" ✓ fam_admins collection deleted");
} else {
console.log(" ↳ fam_admins already removed");
}
const famsCol = await getCollection("fams");
if (famsCol && famsCol.fields.some((f: any) => f.name === "inviteCode")) {
famsCol.fields = famsCol.fields.filter(
(f: any) => f.name !== "inviteCode",
);
await updateCollection(famsCol.id, {
name: "fams",
type: "base",
listRule: famsCol.listRule || "",
viewRule: famsCol.viewRule || "",
createRule: famsCol.createRule,
updateRule: famsCol.updateRule,
deleteRule: famsCol.deleteRule,
fields: famsCol.fields,
});
console.log(" ✓ fams.inviteCode field removed");
}
}
console.log("[migrate] Done"); console.log("[migrate] Done");
} }
+5 -28
View File
@@ -103,7 +103,6 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
[ [
text("name", true), text("name", true),
uniqueText("slug"), uniqueText("slug"),
text("inviteCode"),
text("stripeCustomerId"), text("stripeCustomerId"),
jsonField("featureFlags"), jsonField("featureFlags"),
jsonField("seasons"), jsonField("seasons"),
@@ -132,17 +131,6 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
build: (ids) => build: (ids) =>
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")])(ids), col("settings", [rel("famId", ids.fams, true), text("webhookUrl")])(ids),
}, },
{
name: "members",
build: (ids) =>
col("members", [
rel("famId", ids.fams, true),
text("name", true),
text("color"),
text("deviceToken"),
text("deviceTokenHint"),
])(ids),
},
{ {
name: "chore_templates", name: "chore_templates",
build: (ids) => build: (ids) =>
@@ -155,17 +143,6 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
number("defaultValue", true), number("defaultValue", true),
])(ids), ])(ids),
}, },
{
name: "fam_admins",
build: (ids) =>
col("fam_admins", [
rel("famId", ids.fams, true),
text("userId", true),
text("email", true),
text("name"),
text("color"),
])(ids),
},
{ {
name: "bonus_configs", name: "bonus_configs",
build: (ids) => build: (ids) =>
@@ -179,7 +156,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
select("rewardType", ["points", "cash", "prize"], true), select("rewardType", ["points", "cash", "prize"], true),
text("rewardValue", true), text("rewardValue", true),
number("criteriaValue"), number("criteriaValue"),
rel("memberId", ids.members), rel("memberId", ids.users),
select("period", ["schedule", "daily", "weekly", "monthly"]), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true), select("status", ["active", "completed"], true),
])(ids), ])(ids),
@@ -189,7 +166,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
build: (ids) => build: (ids) =>
col("weekly_history", [ col("weekly_history", [
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.members, true), rel("memberId", ids.users, true),
date("weekStart"), date("weekStart"),
number("pointsEarned"), number("pointsEarned"),
number("moneyEarned"), number("moneyEarned"),
@@ -241,7 +218,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
build: (ids) => build: (ids) =>
col("rewards", [ col("rewards", [
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.members, true), rel("memberId", ids.users, true),
rel("bonusConfigId", ids.bonus_configs), rel("bonusConfigId", ids.bonus_configs),
text("label", true), text("label", true),
number("value", true), number("value", true),
@@ -259,7 +236,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
build: (ids) => build: (ids) =>
col("assigned_chores", [ col("assigned_chores", [
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.members, true), rel("memberId", ids.users, true),
rel("templateId", ids.chore_templates, true), rel("templateId", ids.chore_templates, true),
select("frequency", ["daily", "weekly"], true), select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money"], true), select("type", ["points", "money"], true),
@@ -273,7 +250,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
build: (ids) => build: (ids) =>
col("completions", [ col("completions", [
rel("famId", ids.fams, true), rel("famId", ids.fams, true),
rel("memberId", ids.members, true), rel("memberId", ids.users, true),
rel("assignedChoreId", ids.assigned_chores, true), rel("assignedChoreId", ids.assigned_chores, true),
date("date"), date("date"),
date("completedAt"), date("completedAt"),