From 5204e7bdbc1c4f040eb8329f85e8d228eaa0c1b9 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 17 Aug 2026 07:41:26 +0100 Subject: [PATCH 1/3] migrate from hono --- .env.example | 12 +- MEMORY.md | 9 + docker/Dockerfile | 4 - docker/entrypoint.sh | 9 +- docker/nginx.conf | 10 - frontend/src/env.ts | 2 - frontend/src/hooks.server.ts | 4 + frontend/src/lib/client/api.ts | 11 +- frontend/src/lib/server/hono.ts | 246 -- frontend/src/lib/server/migrate-boot.ts | 16 + .../src/lib/server}/migrate.ts | 90 +- frontend/src/lib/server/pocketbase.ts | 5 +- frontend/src/lib/server/routeAuth.ts | 28 + frontend/src/lib/server/services/bonuses.ts | 362 +++ frontend/src/lib/server/services/chat.ts | 55 + frontend/src/lib/server/services/chores.ts | 31 + .../src/lib/server/services/completions.ts | 78 + frontend/src/lib/server/services/crud.ts | 61 + frontend/src/lib/server/services/debug.ts | 60 + frontend/src/lib/server/services/fam.ts | 452 ++++ frontend/src/lib/server/services/index.ts | 101 + frontend/src/lib/server/services/rewards.ts | 68 + frontend/src/lib/server/services/settings.ts | 27 + frontend/src/lib/server/servicesFor.ts | 11 + frontend/src/lib/stores/chat.svelte.ts | 2 +- frontend/src/routes/[fam]/+layout.server.ts | 79 +- frontend/src/routes/[fam]/+page.server.ts | 14 +- .../routes/[fam]/[username]/+page.server.ts | 62 +- .../[fam]/[username]/bonuses/+page.server.ts | 51 +- .../bonuses/progress.json/+server.ts | 7 +- .../[fam]/[username]/ledger/+page.server.ts | 18 +- .../[username]/preferences/+page.server.ts | 89 +- .../[fam]/[username]/settings/+page.server.ts | 10 +- .../admin/[famId]/assigned-chores/+server.ts | 19 + .../[famId]/assigned-chores/[id]/+server.ts | 19 + frontend/src/routes/api/chat/+server.ts | 41 + .../routes/api/completions/toggle/+server.ts | 15 + .../routes/api/fam/[famId]/payday/+server.ts | 14 + frontend/src/routes/api/members/me/+server.ts | 15 + .../api/members/rewards/[id]/claim/+server.ts | 15 + frontend/src/routes/chat/+server.ts | 58 - frontend/vite.config.ts | 11 +- package.json | 4 +- pnpm-lock.yaml | 47 +- pnpm-workspace.yaml | 3 +- proxy/.gitignore | 1 - proxy/package.json | 21 - proxy/scripts/seed.ts | 68 - proxy/scripts/test-admin.ts | 99 - proxy/scripts/test-auth.ts | 63 - proxy/src/env.ts | 21 - proxy/src/index.ts | 2338 ----------------- proxy/src/pb.ts | 92 - proxy/tsconfig.json | 18 - shared/config.ts | 7 +- shared/pb/schema.ts | 99 +- 56 files changed, 1815 insertions(+), 3357 deletions(-) delete mode 100644 frontend/src/lib/server/hono.ts create mode 100644 frontend/src/lib/server/migrate-boot.ts rename {proxy/src => frontend/src/lib/server}/migrate.ts (96%) create mode 100644 frontend/src/lib/server/routeAuth.ts create mode 100644 frontend/src/lib/server/services/bonuses.ts create mode 100644 frontend/src/lib/server/services/chat.ts create mode 100644 frontend/src/lib/server/services/chores.ts create mode 100644 frontend/src/lib/server/services/completions.ts create mode 100644 frontend/src/lib/server/services/crud.ts create mode 100644 frontend/src/lib/server/services/debug.ts create mode 100644 frontend/src/lib/server/services/fam.ts create mode 100644 frontend/src/lib/server/services/index.ts create mode 100644 frontend/src/lib/server/services/rewards.ts create mode 100644 frontend/src/lib/server/services/settings.ts create mode 100644 frontend/src/lib/server/servicesFor.ts create mode 100644 frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts create mode 100644 frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts create mode 100644 frontend/src/routes/api/chat/+server.ts create mode 100644 frontend/src/routes/api/completions/toggle/+server.ts create mode 100644 frontend/src/routes/api/fam/[famId]/payday/+server.ts create mode 100644 frontend/src/routes/api/members/me/+server.ts create mode 100644 frontend/src/routes/api/members/rewards/[id]/claim/+server.ts delete mode 100644 frontend/src/routes/chat/+server.ts delete mode 100644 proxy/.gitignore delete mode 100644 proxy/package.json delete mode 100644 proxy/scripts/seed.ts delete mode 100644 proxy/scripts/test-admin.ts delete mode 100644 proxy/scripts/test-auth.ts delete mode 100644 proxy/src/env.ts delete mode 100644 proxy/src/index.ts delete mode 100644 proxy/src/pb.ts delete mode 100644 proxy/tsconfig.json diff --git a/.env.example b/.env.example index 8dcb1da..8f9b6fb 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ -# Runtime env for the SvelteKit + Hono app. +# Runtime env for the SvelteKit app. # -# The app's own loopback URLs are constants in code (PROXY_URL / PB_ENDPOINT); -# ports live in config.ts for the proxy. Only these are real env vars: +# The app's own loopback URL (PB_ENDPOINT) is computed in code; only these are +# real env vars: # PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123. PB_EMAIL= @@ -9,11 +9,11 @@ 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 -# your remote IP changes — the browser (pocketbase.ts) and pb-admin read it. +# Public: the dev machine's IP where PB runs. Change this when your remote IP +# changes — the browser (pocketbase.ts) and server-side reads use it. # Prod ignores this (uses /pb via nginx). Default: 192.168.1.225. SERVER_IP=192.168.1.225 # docker-compose (staging) — host-side deploy config, never baked into the image. PORT=3001 # public port to publish (nginx container listens on 3001) -PB_DATA=./pb_data # where to persist PocketBase data on the host +PB_DATA=./pb_data # where to persist PocketBase data on the host \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md index f15e3b4..9bc4e2c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,14 @@ # FamChore v2 — Development Memory +## 2026-08-17 — Hono proxy removed: everything runs in SvelteKit services + +- **Decision**: deleted the `proxy/` Hono service entirely. All business logic (admin CRUD, member kanban, weekly summary/EOW, bonus evaluation/trigger/progress, rewards claim/issue, chat, payday settlement, debug data-gen) now lives in `frontend/src/lib/server/services/`, grouped **by app area** (not by role): `fam.ts`, `chores.ts`, `completions.ts`, `rewards.ts`, `bonuses.ts`, `chat.ts`, `settings.ts`, `crud.ts`, `debug.ts`, plus a generic per-resource `crud.ts`. `createServices(pb, user)` returns a per-feature binder; `servicesFor(event)` is the shorthand for loads/form actions. **No role guard** — PB collection rules on the acting user's token are the security boundary (the `admin`/`member` split no longer exists as separate files). +- **Wiring**: `hono.admin.*` (form actions/loads) and `memberApi.*`/chat are now direct service calls or SvelteKit `/api/*` routes (`completions/toggle`, `members/rewards/[id]/claim`, `members/me`, `fam/[famId]/payday`, `chat`, `admin/[famId]/assigned-chores`). Browser admin calls (chores grid) hit SvelteKit `/api/admin/*`. `routeAuth.actingClient(event)` resolves the acting user's PB client from the Bearer header or the `pb_token` cookie. +- **Migration relocated**: `proxy/src/migrate.ts` → `frontend/src/lib/server/migrate.ts` (env now via `$app/env/private` + `PB_ENDPOINT` from `pocketbase.ts`), run once per process by `migrate-boot.ts`, kicked off in `hooks.server.ts` (`void migrateOnBoot()`). Schema source of truth remains `shared/pb/schema.ts`. +- **Infra**: `pnpm-workspace.yaml` (only `frontend`), root `package.json` (`dev` = `pnpm --filter frontend dev`), `docker/Dockerfile` (no proxy build/deploy), `docker/entrypoint.sh` (no proxy start; app runs schema migration on boot), `docker/nginx.conf` (`/api/` block removed → falls through to `location /` → SvelteKit `:3000`; `/pb/api/` unchanged). Removed `PROXY_URL` env + `PROXY_PORT` from `shared/config.ts` and `frontend/src/env.ts`. Dead `memberApi.myChores`/`requestAll` removed. +- **Typecheck**: frontend `svelte-check` = 12 pre-existing canary errors (`.svelte` implicit-any, qrcode decl, RewardType/Frequency casts, signup `string|undefined`); **zero errors in the migration's files**. `pnpm build` (adapter-node) succeeds. +- **Note**: dev servers were left running; the now-deleted proxy `tsx watch` (`:3456`) will error and the frontend dev server needs a restart to drop `PROXY_URL`/load `migrateOnBoot` + the removed `/api` Vite proxy. + ## UI Component Architecture (Jul 2026) ### Layout Hierarchy diff --git a/docker/Dockerfile b/docker/Dockerfile index 1d7013b..a55920f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,7 +9,6 @@ COPY . . RUN pnpm install --frozen-lockfile RUN pnpm --filter frontend build -RUN pnpm --filter proxy build # Create a standalone production node_modules for frontend RUN pnpm --filter frontend deploy --prod /deploy/frontend @@ -29,9 +28,6 @@ COPY --from=builder /app/frontend/build ./frontend COPY --from=builder /deploy/frontend/node_modules ./frontend/node_modules COPY --from=builder /deploy/frontend/package.json ./frontend/package.json -# Proxy is bundled into one JS file by esbuild -COPY --from=builder /app/proxy/dist ./proxy - COPY docker/nginx.conf /etc/nginx/http.d/default.conf COPY docker/entrypoint.sh /entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9c75c86..512c4b8 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -16,7 +16,8 @@ fi # automigrate generates conflicting snapshots on upgraded stores. pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" --automigrate=false & -# Wait for PB to be healthy before starting the proxy (which runs migrate). +# Wait for PB to be healthy before starting the app (which runs the schema +# migration on boot). echo "[entrypoint] Waiting for PocketBase..." for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then @@ -26,10 +27,8 @@ for i in $(seq 1 30); do sleep 1 done -# Start the app (frontend + proxy). The proxy auto-runs schema migration. -# FRONTEND_PORT/PROXY_PORT are set via ENV in the Dockerfile; adapter-node -# reads PORT, the proxy reads PROXY_PORT. +# Start the app (SvelteKit + adapter-node). It auto-runs the schema migration. +# PORT defaults to 3000 (adapter-node); nginx proxies to it. node /app/frontend/index.js & -node /app/proxy/index.js & nginx -g 'daemon off;' diff --git a/docker/nginx.conf b/docker/nginx.conf index cf676f5..f5081b4 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -11,16 +11,6 @@ server { proxy_cache_bypass $http_upgrade; } - # App's Hono proxy (/api/*). - location /api/ { - proxy_pass http://127.0.0.1:3456; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - # Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the # /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything # else under /pb/) is intentionally NOT proxied, keeping it internal. diff --git a/frontend/src/env.ts b/frontend/src/env.ts index c05d112..c1d2a6b 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -10,8 +10,6 @@ const withDefault = (value: string) => ({ } as const); export const variables = defineEnvVars({ - // SSR → Hono proxy (loopback, proxy runs on the same host as SSR). - PROXY_URL: { public: true, schema: withDefault('http://127.0.0.1:3456') }, SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, // PB superuser creds (server-only). PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') }, diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 1fe8e04..f94b1ca 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -3,6 +3,10 @@ import { createPbClient } from '$lib/server/pocketbase'; import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session'; import type { SessionUser } from '$lib/server/types'; import { handleOf } from '@shared/slugify'; +import { migrateOnBoot } from '$lib/server/migrate-boot'; + +// Run the PB schema migration once at server boot (idempotent). +void migrateOnBoot(); export const handle: Handle = async ({ event, resolve }) => { event.locals.user = null; diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index 9784711..0f90c67 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -1,5 +1,4 @@ -// Client-only. All /api calls go same-origin (vite proxy in dev, nginx in -// prod). Server-side (SSR) calls use PROXY_URL from $app/env/public instead. +// Client-only. All /api calls go same-origin (SvelteKit in dev and prod). const BASE_URL = ''; async function memberFetch( @@ -27,16 +26,10 @@ export const memberApi = { async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) { return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date }); }, - async myChores(token: string, famId: string) { - return memberFetch('POST', '/api/members/my-chores', token, famId); - }, async claimReward(token: string, famId: string, rewardId: string) { return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId); }, - async requestAllRewards(token: string, famId: string) { - return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId); - }, async payday(token: string, famId: string) { return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId); }, -}; +}; \ No newline at end of file diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts deleted file mode 100644 index 66034b8..0000000 --- a/frontend/src/lib/server/hono.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { RequestEvent } from '@sveltejs/kit'; -import { PROXY_URL } from '$app/env/public'; - -function sessionHeaders(event: RequestEvent): Record { - const u = event.locals.user; - if (!u) return {}; - return { - 'x-session-famid': u.famId, - 'x-session-userid': u.id, - 'Content-Type': 'application/json' - }; -} - -async function request( - method: string, - path: string, - body?: unknown, - headers?: Record -) { - const res = await fetch(`${PROXY_URL}${path}`, { - method, - headers: headers || { 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined - }); - const text = await res.text(); - let data: any = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch { - data = { raw: text }; - } - if (!res.ok) { - const msg = data?.error || data?.message || `${method} ${path} failed (HTTP ${res.status})`; - throw new Error(msg); - } - return data; -} - -export const hono = { - admin: { - async list(event: RequestEvent, resource: string, famId: string) { - return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event)); - }, - async create( - event: RequestEvent, - resource: string, - famId: string, - data: Record - ) { - return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event)); - }, - async update( - event: RequestEvent, - resource: string, - famId: string, - id: string, - data: Record - ) { - return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event)); - }, - async remove(event: RequestEvent, resource: string, famId: string, id: string) { - return request( - 'DELETE', - `/api/admin/${famId}/${resource}/${id}`, - undefined, - sessionHeaders(event) - ); - }, - async renameFam(event: RequestEvent, famId: string, name: string) { - return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event)); - }, - async updatePayday( - event: RequestEvent, - famId: string, - payday: number, - paydayTime?: string, - timezone?: string - ) { - return request( - 'PATCH', - `/api/admin/${famId}/fam`, - { - payday, - ...(paydayTime !== undefined ? { paydayTime } : {}), - ...(timezone !== undefined ? { timezone } : {}) - }, - sessionHeaders(event) - ); - }, - async fam(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event)); - }, - async verify(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event)); - }, - async weeklySummary(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event)); - }, - async completions(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/completions`, undefined, sessionHeaders(event)); - }, - async rewards(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event)); - }, - async claimReward(event: RequestEvent, famId: string, rewardId: string) { - return request( - 'POST', - `/api/admin/${famId}/rewards/${rewardId}/claim`, - undefined, - sessionHeaders(event) - ); - }, - async issueAllRewards(event: RequestEvent, famId: string, memberId: string) { - return request( - 'POST', - `/api/admin/${famId}/rewards/issue-all`, - { memberId }, - sessionHeaders(event) - ); - }, - async bonusConfigs(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event)); - }, - async bonusConfigProgress(event: RequestEvent, famId: string) { - return request( - 'GET', - `/api/admin/${famId}/bonus-configs/progress`, - undefined, - sessionHeaders(event) - ); - }, - async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) { - return request( - 'POST', - `/api/admin/${famId}/bonus-configs/evaluate`, - { configId }, - sessionHeaders(event) - ); - }, - async triggerBonusConfig( - event: RequestEvent, - famId: string, - configId: string, - memberId?: string - ) { - return request( - 'POST', - `/api/admin/${famId}/bonus-configs/${configId}/trigger`, - { memberId }, - sessionHeaders(event) - ); - }, - async assignBonusConfig( - event: RequestEvent, - famId: string, - configId: string, - data: Record - ) { - return request( - 'POST', - `/api/admin/${famId}/bonus-configs/${configId}/assign`, - data, - sessionHeaders(event) - ); - }, - async completeBonusConfig(event: RequestEvent, famId: string, configId: string) { - return request( - 'POST', - `/api/admin/${famId}/bonus-configs/${configId}/complete`, - undefined, - sessionHeaders(event) - ); - }, - async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) { - return request( - 'POST', - `/api/admin/${famId}/bonus-configs/${configId}/destroy`, - undefined, - sessionHeaders(event) - ); - }, - async bonusConfigTallies(event: RequestEvent, famId: string) { - return request( - 'GET', - `/api/admin/${famId}/bonus-configs/tallies`, - undefined, - sessionHeaders(event) - ); - }, - async revokeCompletion(event: RequestEvent, famId: string, completionId: string) { - return request( - 'POST', - `/api/admin/${famId}/completions/${completionId}/revoke`, - undefined, - sessionHeaders(event) - ); - }, - async memberChores(event: RequestEvent, famId: string, memberId: string) { - return request( - 'GET', - `/api/admin/${famId}/members/${memberId}/chores`, - undefined, - sessionHeaders(event) - ); - }, - async updateMember( - event: RequestEvent, - famId: string, - memberId: string, - data: Record - ) { - return request( - 'PATCH', - `/api/admin/${famId}/members/${memberId}`, - data, - sessionHeaders(event) - ); - }, - async getProfile(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event)); - }, - async updateProfile(event: RequestEvent, famId: string, data: Record) { - return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event)); - }, - async updateFam(event: RequestEvent, famId: string, data: Record) { - return request('PATCH', `/api/admin/${famId}/fam`, data, sessionHeaders(event)); - }, - async request(event: RequestEvent, method: string, path: string, body?: unknown) { - return request(method, path, body, sessionHeaders(event)); - }, - async settings(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/settings`, undefined, sessionHeaders(event)); - }, - async updateSettings(event: RequestEvent, famId: string, data: Record) { - return request('PATCH', `/api/admin/${famId}/settings`, data, sessionHeaders(event)); - }, - async eowPreview(event: RequestEvent, famId: string) { - return request( - 'GET', - `/api/admin/${famId}/debug/eow-preview`, - undefined, - sessionHeaders(event) - ); - } - } -}; diff --git a/frontend/src/lib/server/migrate-boot.ts b/frontend/src/lib/server/migrate-boot.ts new file mode 100644 index 0000000..5ba4af4 --- /dev/null +++ b/frontend/src/lib/server/migrate-boot.ts @@ -0,0 +1,16 @@ +import { migrate } from './migrate'; + +// Runs the PocketBase schema migration once per server process, at boot. +// Idempotent (migrate() diffs against existing collections), so re-running on +// dev HMR or restarts is a cheap no-op. Errors are logged, not thrown, so a +// migration hiccup never takes the server down. +let done: Promise | null = null; + +export function migrateOnBoot(): Promise { + if (!done) { + done = migrate().catch((e) => { + console.error('[migrate] failed:', e); + }); + } + return done; +} \ No newline at end of file diff --git a/proxy/src/migrate.ts b/frontend/src/lib/server/migrate.ts similarity index 96% rename from proxy/src/migrate.ts rename to frontend/src/lib/server/migrate.ts index 0c3443a..145ea21 100644 --- a/proxy/src/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -1,5 +1,6 @@ -import { SCHEMA_PLAN } from "@shared/pb/schema.ts"; -import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; +import { SCHEMA_PLAN } from '@shared/pb/schema'; +import { PB_ENDPOINT } from '$lib/server/pocketbase'; +import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; let token: string | null = null; @@ -1622,70 +1623,75 @@ export async function migrate(): Promise { } // ── 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). + // Replaces the old "superuser-only writes via Hono" model. SvelteKit writes + // as the authenticated user, so 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). + // + // Admin-only collections additionally require role='parent'; child-accessible + // collections (completions toggle, reward claim, chat) are famId-scoped only. { - const WRITE_RULE = "@request.body.famId = @request.auth.famId"; - const SCOPED_RULE = "famId = @request.auth.famId"; - const WRITE_SCOPED_COLLECTIONS = [ - "members", + const PARENT_WRITE = + "@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'"; + const PARENT_SCOPED = "famId = @request.auth.famId && @request.auth.role = 'parent'"; + const FAM_WRITE = "@request.body.famId = @request.auth.famId"; + const FAM_SCOPED = "famId = @request.auth.famId"; + const ADMIN_ONLY = [ "chore_templates", "assigned_chores", - "completions", - "bonus_configs", "bonus_templates", - "rewards", - "seasons", + "bonus_configs", "settings", "weekly_history", "monthly_bonuses", - "messages", - "chat_typing", + "seasons", ]; - for (const name of WRITE_SCOPED_COLLECTIONS) { + const CHILD_ACCESSIBLE = ["completions", "rewards", "messages", "chat_typing"]; + for (const name of [...ADMIN_ONLY, ...CHILD_ACCESSIBLE]) { const c = await getCollection(name); if (!c) continue; - if ( - c.createRule === WRITE_RULE && - c.updateRule === SCOPED_RULE && - c.deleteRule === SCOPED_RULE - ) { - continue; - } + const isParent = ADMIN_ONLY.includes(name); + const write = isParent ? PARENT_WRITE : FAM_WRITE; + const scoped = isParent ? PARENT_SCOPED : FAM_SCOPED; + if (c.createRule === write && c.updateRule === scoped && c.deleteRule === scoped) continue; await updateCollection(c.id, { name, type: c.type, listRule: c.listRule, viewRule: c.viewRule, - createRule: WRITE_RULE, - updateRule: SCOPED_RULE, - deleteRule: SCOPED_RULE, + createRule: write, + updateRule: scoped, + deleteRule: scoped, fields: c.fields, }); - console.log(` ↳ Locked ${name} write rules to famId scoping`); + console.log(` ↳ Locked ${name} write rules (${isParent ? "parent" : "fam"} scoping)`); } } - // ── 28b. Allow each admin to UPDATE their own fam record ── + // ── 28b. Allow each user to READ + 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. + // to the caller's famId. Reads are enabled so both parents and children can + // load their fam via their own token; 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)"); + if (c) { + const wantList = c.listRule !== "id = @request.auth.famId"; + const wantView = c.viewRule !== "id = @request.auth.famId"; + const wantUpdate = c.updateRule !== "id = @request.auth.famId"; + if (wantList || wantView || wantUpdate) { + await updateCollection(c.id, { + name: "fams", + type: c.type, + listRule: "id = @request.auth.famId", + viewRule: "id = @request.auth.famId", + createRule: c.createRule, + updateRule: "id = @request.auth.famId", + deleteRule: c.deleteRule, + fields: c.fields, + }); + console.log(" ↳ fams read+update scoped to own record (id = @request.auth.famId)"); + } } } diff --git a/frontend/src/lib/server/pocketbase.ts b/frontend/src/lib/server/pocketbase.ts index 465c80d..09d4543 100644 --- a/frontend/src/lib/server/pocketbase.ts +++ b/frontend/src/lib/server/pocketbase.ts @@ -36,9 +36,8 @@ export async function createSuperClient() { return pb; } -// Superuser CRUD facade, built on the memoized SDK superuser client. Replaces -// the old raw-fetch `pb-admin.ts`/`ensureToken` path so all server PB access -// (authenticated user + superuser) lives in this one module. +// Superuser CRUD facade, built on the memoized SDK superuser client. All +// server PB access (authenticated user + superuser) lives in this one module. export const pbAdmin = { async getList(collection: string, filter = '') { const pb = await createSuperClient(); diff --git a/frontend/src/lib/server/routeAuth.ts b/frontend/src/lib/server/routeAuth.ts new file mode 100644 index 0000000..0b6cee2 --- /dev/null +++ b/frontend/src/lib/server/routeAuth.ts @@ -0,0 +1,28 @@ +import { error } from '@sveltejs/kit'; +import { createPbClient } from '$lib/server/pocketbase'; +import type { RequestEvent } from '@sveltejs/kit'; + +// Resolve the acting user's PB client from a request: prefer the Authorization +// Bearer token (sent by the browser member API), else the httpOnly session +// cookie. Identity comes from the verified session. Used by the in-app /api/* +// routes that replaced the Hono member endpoints. +export function actingClient(event: RequestEvent) { + const token = + event.request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') || + event.locals.pbToken || + ''; + const u = event.locals.user; + if (!u || !token) throw error(401, 'Unauthorized'); + return { + pb: createPbClient(token), + famId: u.famId, + userId: u.id, + role: u.role, + name: u.name || '', + color: u.color || '#6366f1' + }; +} + +export function err(e: unknown) { + return error(500, e instanceof Error ? e.message : 'Internal error'); +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts new file mode 100644 index 0000000..993568c --- /dev/null +++ b/frontend/src/lib/server/services/bonuses.ts @@ -0,0 +1,362 @@ +import { + todayInTz, + resolveTz, + periodStart, + periodEnd, + nextPaydayAfter, + weekStart +} from '@shared/timezone'; +import { famMeta } from './fam'; + +function resolveServerTz(tz?: string): string { + return resolveTz(tz || 'auto'); +} + +function claimableStamp(cfg: any, payday: number, tz: string) { + if (cfg.period !== 'weekly' && cfg.period !== 'monthly') { + return { claimable: 'immediate', settleDate: '' }; + } + const now = todayInTz(resolveServerTz(tz)); + const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz); + const end = periodEnd(cfg.period, start); + return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) }; +} + +export async function evaluateFam(pb: any, famId: string) { + let configs: any[] = []; + try { + configs = await pb + .collection('bonus_configs') + .getFullList({ filter: `famId = '${famId}' && status = 'active' && type != 'manual'` }); + } catch { + return; + } + if (!configs.length) return; + + const [allMembers, allAssigned, allCompletions] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) + ]); + let allRewards: any[] = []; + try { + allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); + } catch {} + const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId); + + for (const cfg of configs) { + const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : ''; + const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : ''; + const periodCompletions = cfg.period + ? allCompletions.filter( + (c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd + ) + : allCompletions; + + const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); + let createdReward = false; + + const rewardData = (memberId: string) => { + const label = + cfg.rewardType === 'cash' + ? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}` + : `${cfg.name} – ${cfg.rewardValue}`; + const now = new Date().toISOString(); + return { + famId, + memberId, + bonusConfigId: cfg.id, + label, + value: Number(cfg.rewardValue) || 0, + rewardType: cfg.rewardType, + status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', + claimedAt: cfg.rewardType === 'points' ? now : null, + date: now.slice(0, 10), + ...claimableStamp(cfg, paydayEval, tzEval) + }; + }; + + if (cfg.target === 'individual') { + const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers; + for (const m of targetMembers) { + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + let current = 0; + if (cfg.type === 'threshold') { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + current = memberCompletions.length; + } + const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue); + + const memberReward = existingRewards.find((r: any) => r.memberId === m.id); + + if (memberReward && !achieved) { + if (memberReward.status !== 'claimed') { + try { + await pb.collection('rewards').delete(memberReward.id); + } catch {} + } + continue; + } + + if (memberReward) continue; + + if (achieved) { + await pb.collection('rewards').create(rewardData(m.id)); + createdReward = true; + } + } + } else if (cfg.target === 'collaborative') { + const allMemberIds = allMembers.map((m: any) => m.id); + const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); + let total = 0; + if (cfg.type === 'threshold') { + total = teamCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + total = teamCompletions.length; + } + const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue); + + if (!achieved && existingRewards.length > 0) { + for (const r of existingRewards) { + if (r.status !== 'claimed') { + try { + await pb.collection('rewards').delete(r.id); + } catch {} + } + } + continue; + } + + if (achieved && existingRewards.length === 0) { + for (const m of allMembers) { + await pb.collection('rewards').create(rewardData(m.id)); + createdReward = true; + } + } + } else if (cfg.target === 'competitive') { + const scored = allMembers.map((m: any) => { + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + let current = 0; + if (cfg.type === 'threshold') { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + current = memberCompletions.length; + } + return { memberId: m.id, name: m.name, current }; + }); + const qualified = scored.filter((s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)); + const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0); + const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0]; + + if (existingRewards.length > 0) { + const existing = existingRewards[0]; + const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0; + if (!stillValid && existing.status !== 'claimed') { + try { + await pb.collection('rewards').delete(existing.id); + } catch {} + } + } + + if (winner && existingRewards.length === 0) { + await pb.collection('rewards').create(rewardData(winner.memberId)); + createdReward = true; + } + } + + if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) { + try { + await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' }); + } catch {} + } + } +} + +export async function evaluateAll(pb: any, famId: string) { + await evaluateFam(pb, famId); + return { evaluated: true }; +} + +export async function progress(pb: any, famId: string) { + const { payday, tz } = await famMeta(pb, famId); + let configsData: any[] = []; + try { + configsData = await pb + .collection('bonus_configs') + .getFullList({ filter: `famId = '${famId}' && status = 'active'` }); + } catch {} + const [members, assigned, completions] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) + ]); + + const assignedList = assigned; + const completionsList = completions; + let allRewards: any[] = []; + try { + allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); + } catch {} + + const result: any[] = []; + + for (const cfg of configsData) { + const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); + const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : ''; + const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ''; + const periodCompletions = cfg.period + ? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd) + : completionsList; + + const progressRows: any[] = []; + + if (cfg.target === 'collaborative') { + const teamCompletions = periodCompletions.filter((c: any) => + members.some((m: any) => m.id === c.memberId) + ); + let teamCurrent = 0; + if (cfg.type === 'threshold') { + teamCurrent = teamCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + teamCurrent = teamCompletions.length; + } + const teamReward = cfgRewards[0]; + progressRows.push({ + memberId: '__team__', + memberName: 'Team Total', + memberColor: '#8b5cf6', + current: teamCurrent, + criteriaValue: cfg.criteriaValue || 0, + reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null, + state: teamReward ? teamReward.status : 'pending', + achieved: teamReward ? true : false + }); + } + + if (cfg.target !== 'collaborative') { + const progressMembers = + cfg.target === 'individual' && cfg.memberId + ? members.filter((m: any) => m.id === cfg.memberId) + : members; + for (const m of progressMembers) { + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + const memberReward = cfgRewards.find((r: any) => r.memberId === m.id); + + let current = 0; + if (cfg.type === 'threshold') { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + current = memberCompletions.length; + } else if (cfg.type === 'manual') { + current = 0; + } + + progressRows.push({ + memberId: m.id, + memberName: m.name, + memberColor: m.color, + current, + criteriaValue: cfg.criteriaValue || 0, + reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null, + state: memberReward ? memberReward.status : 'pending', + achieved: memberReward ? true : false + }); + } + } + + result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd }); + } + + return result; +} + +export async function trigger(pb: any, famId: string, configId: string, memberId?: string) { + const configs = await pb + .collection('bonus_configs') + .getFullList({ filter: `famId = '${famId}' && id = '${configId}' && status = 'active'` }); + const cfg = configs?.[0]; + if (!cfg) throw new Error('Bonus config not found'); + if (cfg.type !== 'manual') throw new Error('Only manual-type configs can be triggered'); + + const existingRewards = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && bonusConfigId = '${cfg.id}'` + }); + + const members = await pb + .collection('users') + .getFullList({ filter: `famId = '${famId}' && role = 'child'` }); + + const targetMembers: any[] = []; + if (cfg.target === 'competitive' || cfg.target === 'collaborative') { + for (const m of members) targetMembers.push(m); + } else if (cfg.target === 'individual') { + const targetId = cfg.memberId || memberId; + if (!targetId) throw new Error('memberId required for individual trigger'); + const member = members.find((m: any) => m.id === targetId); + if (!member) throw new Error('Member not found'); + targetMembers.push(member); + } + + for (const m of targetMembers) { + const memberRewards = existingRewards.filter((r: any) => r.memberId === m.id); + if (cfg.occurrence === 'once' && memberRewards.some((r: any) => r.status === 'unclaimed')) { + throw new Error(`Already issued and pending for ${m.name}`); + } + if (cfg.occurrence === 'recurring' && cfg.period) { + const { payday, tz } = await famMeta(pb, famId); + const pStart = periodStart(cfg.period, payday, tz); + const pEnd = periodEnd(cfg.period, pStart); + const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd); + if (periodRewards.length > 0) { + throw new Error( + `Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}` + ); + } + } + } + + const created: any[] = []; + for (const m of targetMembers) { + const label = + cfg.rewardType === 'cash' + ? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}` + : `${cfg.name} – ${cfg.rewardValue}`; + const now = new Date().toISOString(); + const record = await pb.collection('rewards').create({ + famId, + memberId: m.id, + bonusConfigId: cfg.id, + label, + value: Number(cfg.rewardValue) || 0, + rewardType: cfg.rewardType, + status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', + claimedAt: cfg.rewardType === 'points' ? now : null, + date: now.slice(0, 10), + claimable: 'immediate', + settleDate: '' + }); + created.push(record); + } + + if (cfg.occurrence === 'once') { + await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' }); + } + + return { triggered: true, created: created.length, records: created }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/chat.ts b/frontend/src/lib/server/services/chat.ts new file mode 100644 index 0000000..e46b411 --- /dev/null +++ b/frontend/src/lib/server/services/chat.ts @@ -0,0 +1,55 @@ +export type ChatActor = { + id: string; + type: 'admin' | 'member'; + name: string; + color: string; +}; + +export async function chatMe(pb: any, famId: string, actor: ChatActor) { + return { famId, actor }; +} + +export async function send( + pb: any, + famId: string, + actor: ChatActor, + body: { content?: string; clientId?: string } +) { + const content = (body.content || '').trim(); + if (!content) throw new Error('content required'); + return pb.collection('messages').create({ + famId, + authorType: actor.type, + authorId: actor.id, + authorName: actor.name, + authorColor: actor.color, + content, + createdAt: new Date().toISOString(), + clientId: body.clientId ? String(body.clientId).slice(0, 64) : '' + }); +} + +export async function typing( + pb: any, + famId: string, + actor: ChatActor, + body: { typing?: boolean } +) { + const existing = await pb.collection('chat_typing').getFullList({ + filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'` + }); + const row = { + famId, + actorId: actor.id, + actorType: actor.type, + authorName: actor.name, + authorColor: actor.color, + typing: !!body.typing + }; + if (existing?.length) { + await pb.collection('chat_typing').update(existing[0].id, row); + } else { + await pb.collection('chat_typing').create(row); + } + return { ok: true }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/chores.ts b/frontend/src/lib/server/services/chores.ts new file mode 100644 index 0000000..e39145b --- /dev/null +++ b/frontend/src/lib/server/services/chores.ts @@ -0,0 +1,31 @@ +import { famMeta } from './fam'; + +// Aggregated kanban payload for a single member (child session). +export async function myChores(pb: any, famId: string, memberId: string) { + const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ + pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), + pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), + pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) + ]); + const { payday, paydayTime, tz } = await famMeta(pb, famId); + let settings: any = {}; + try { + const s = await pb + .collection('settings') + .getFullList({ filter: `famId = '${famId}'` }); + settings = s?.[0] || {}; + } catch {} + return { + templates, + assigned, + completions, + rewards, + bonusConfigs, + payday, + paydayTime, + timezone: tz, + simulateEow: !!settings.simulateEow + }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/completions.ts b/frontend/src/lib/server/services/completions.ts new file mode 100644 index 0000000..c8166ab --- /dev/null +++ b/frontend/src/lib/server/services/completions.ts @@ -0,0 +1,78 @@ +import { periodWindow } from '@shared/timezone'; +import { famMeta } from './fam'; +import { evaluateFam } from './bonuses'; + +export async function myChores(pb: any, famId: string, memberId: string) { + const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ + pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), + pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), + pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) + ]); + const { payday, paydayTime, tz } = await famMeta(pb, famId); + let settings: any = {}; + try { + const s = await pb + .collection('settings') + .getFullList({ filter: `famId = '${famId}'` }); + settings = s?.[0] || {}; + } catch {} + return { + templates, + assigned, + completions, + rewards, + bonusConfigs, + payday, + paydayTime, + timezone: tz, + simulateEow: !!settings.simulateEow + }; +} + +export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) { + const { assignedChoreId, date } = body; + if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required'); + + const choreList = await pb + .collection('assigned_chores') + .getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` }); + const chore = choreList?.[0]; + const isTodo = chore?.isTodo; + let filter: string; + if (isTodo) { + filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`; + } else { + const { payday, tz } = await famMeta(pb, famId); + const { from, to } = periodWindow(chore?.frequency, payday, tz); + filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`; + } + const existing = await pb + .collection('completions') + .getFullList({ filter }); + if (existing?.length > 0) { + await pb.collection('completions').delete(existing[0].id); + evaluateFam(pb, famId).catch(() => {}); + return { completed: false }; + } + const record = await pb.collection('completions').create({ + famId, + memberId, + assignedChoreId, + date, + completedAt: new Date().toISOString() + }); + evaluateFam(pb, famId).catch(() => {}); + return { completed: true, record }; +} + +export async function revoke(pb: any, famId: string, completionId: string) { + const completions = await pb + .collection('completions') + .getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` }); + if (!completions?.length) throw new Error('Completion not found'); + await pb.collection('completions').delete(completionId); + evaluateFam(pb, famId).catch(() => {}); + return { revoked: true }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/crud.ts b/frontend/src/lib/server/services/crud.ts new file mode 100644 index 0000000..faf9583 --- /dev/null +++ b/frontend/src/lib/server/services/crud.ts @@ -0,0 +1,61 @@ +// Generic per-resource CRUD, mirroring the old proxy's /api/admin/:famId/. +// Kept generic because many pages (chore templates, bonus templates, members, +// assigned-chores, seasons) only need plain list/create/update/delete. + +const RESOURCES: Record< + string, + { col: string; listFilter: (famId: string) => string; bonus?: 'config' | 'template' } +> = { + 'chore-templates': { col: 'chore_templates', listFilter: (f) => `famId = '${f}'` }, + members: { col: 'users', listFilter: (f) => `famId = '${f}' && role = 'child'` }, + 'assigned-chores': { col: 'assigned_chores', listFilter: (f) => `famId = '${f}'` }, + 'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `famId = '${f}'`, bonus: 'template' }, + 'bonus-configs': { col: 'bonus_configs', listFilter: (f) => `famId = '${f}'`, bonus: 'config' }, + completions: { col: 'completions', listFilter: (f) => `famId = '${f}'` }, + rewards: { col: 'rewards', listFilter: (f) => `famId = '${f}'` }, + seasons: { col: 'seasons', listFilter: (f) => `famId = '${f}'` } +}; + +function res(resource: string) { + const r = RESOURCES[resource]; + if (!r) throw new Error(`Unknown resource: ${resource}`); + return r; +} + +function bonusBody(c: { bonus?: 'config' | 'template' }, data: Record) { + const body: Record = { ...data }; + if (body.occurrence === 'once') body.period = ''; + if (c.bonus === 'config') body.status = 'active'; + return body; +} + +export async function list(pb: any, resource: string, famId: string) { + const c = res(resource); + return pb.collection(c.col).getFullList({ filter: c.listFilter(famId) }); +} + +export async function create( + pb: any, + resource: string, + famId: string, + data: Record +) { + const c = res(resource); + return pb.collection(c.col).create({ famId, ...bonusBody(c, data) }); +} + +export async function update( + pb: any, + resource: string, + famId: string, + id: string, + data: Record +) { + const c = res(resource); + return pb.collection(c.col).update(id, bonusBody(c, data)); +} + +export async function remove(pb: any, resource: string, famId: string, id: string) { + const c = res(resource); + return pb.collection(c.col).delete(id); +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/debug.ts b/frontend/src/lib/server/services/debug.ts new file mode 100644 index 0000000..6a93b19 --- /dev/null +++ b/frontend/src/lib/server/services/debug.ts @@ -0,0 +1,60 @@ +// Dev/test helper (settings debugMode) — random completions for a range of days. + +export async function generateData(pb: any, famId: string, days = 7) { + const [members, templates] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) + ]); + + if (!members.length) return { error: 'No members found' }; + if (!templates.length) return { error: 'No templates found' }; + + let completionsCreated = 0; + const today = new Date(); + + for (let d = 0; d < days; d++) { + const date = new Date(today); + date.setDate(date.getDate() - d); + const dateStr = date.toISOString().slice(0, 10); + + for (const m of members) { + const completionRate = 0.5 + Math.random() * 0.5; + for (const t of templates) { + if (Math.random() > completionRate) continue; + + let assigned = await pb.collection('assigned_chores').getFullList({ + filter: `famId = '${famId}' && memberId = '${m.id}' && templateId = '${t.id}'` + }); + let assignedId; + if (assigned?.length > 0) { + assignedId = assigned[0].id; + } else { + const record = await pb.collection('assigned_chores').create({ + famId, + memberId: m.id, + templateId: t.id, + frequency: t.defaultFrequency || 'daily', + type: t.defaultType || 'points', + value: t.defaultValue || 10 + }); + assignedId = record.id; + } + + const existing = await pb.collection('completions').getFullList({ + filter: `famId = '${famId}' && memberId = '${m.id}' && assignedChoreId = '${assignedId}' && date = '${dateStr}'` + }); + if (existing?.length > 0) continue; + + await pb.collection('completions').create({ + famId, + memberId: m.id, + assignedChoreId: assignedId, + date: dateStr + }); + completionsCreated++; + } + } + } + + return { completionsCreated, days }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/fam.ts b/frontend/src/lib/server/services/fam.ts new file mode 100644 index 0000000..3051433 --- /dev/null +++ b/frontend/src/lib/server/services/fam.ts @@ -0,0 +1,452 @@ +import { + weekStart, + addDaysStr, + resolveTz, + periodStart, + periodEnd, + wallClockToUtc +} from '@shared/timezone'; +import { slugify } from '@shared/slugify'; +import { evaluateFam } from './bonuses'; + +function resolveServerTz(tz?: string): string { + return resolveTz(tz || 'auto'); +} + +export async function famMeta(pb: any, famId: string) { + const fam = await pb.collection('fams').getOne(famId); + return { + payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1, + paydayTime: fam.paydayTime || '18:00', + tz: resolveServerTz(fam.timezone) + }; +} + +export async function getFam(pb: any, famId: string) { + const fam = await pb.collection('fams').getOne(famId); + return { + name: fam.name, + slug: fam.slug, + payday: fam.payday, + paydayTime: fam.paydayTime || '18:00', + timezone: fam.timezone || 'auto' + }; +} + +export async function patchFam(pb: any, famId: string, body: Record) { + if (body.name !== undefined) { + const name = body.name as string; + if (!name) throw new Error('name required'); + const slug = slugify(name); + const record = await pb.collection('fams').update(famId, { name, slug }); + return { name: record.name, slug: record.slug, payday: record.payday }; + } + if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) { + const patch: Record = {}; + if (body.payday !== undefined) { + const payday = Number(body.payday); + if (payday < 0 || payday > 6 || !Number.isInteger(payday)) + throw new Error('payday must be 0-6'); + patch.payday = payday; + } + if (body.paydayTime !== undefined) { + const paydayTime = String(body.paydayTime); + if (!/^\d{2}:\d{2}$/.test(paydayTime)) throw new Error('paydayTime must be HH:MM'); + patch.paydayTime = paydayTime; + } + if (body.timezone !== undefined) { + const timezone = String(body.timezone); + if (timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) + throw new Error("timezone must be an IANA name or 'auto'"); + patch.timezone = timezone; + } + const record = await pb.collection('fams').update(famId, patch); + return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone }; + } + throw new Error('no valid fields'); +} + +export async function getProfile(pb: any, famId: string, userId: string) { + const rec = await pb.collection('users').getOne(userId); + return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' }; +} + +export async function updateProfile( + pb: any, + famId: string, + userId: string, + data: Record +) { + const patch: Record = {}; + if (data.name) patch.name = data.name; + if (data.color) patch.color = data.color; + if (data.email !== undefined) patch.email = data.email; + const rec = await pb.collection('users').update(userId, patch); + return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' }; +} + +export async function weeklySummary(pb: any, famId: string) { + const { payday, tz } = await famMeta(pb, famId); + const ws = weekStart(payday, tz); + const [members, assigned, completions] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }) + ]); + + let rewardPointsList: any[] = []; + try { + rewardPointsList = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` + }); + } catch {} + + const assignedList = assigned; + const completionsList = completions; + + const daysInWeek: string[] = []; + { + const d = new Date(ws + 'T00:00:00Z'); + for (let i = 0; i < 7; i++) { + daysInWeek.push(d.toISOString().slice(0, 10)); + d.setDate(d.getDate() + 1); + } + } + + const summaries = await Promise.all( + members.map(async (m: any) => { + const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id); + const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id); + + const weekPoints = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + + const dayPoints: Record = {}; + const dayCompletions: Record = {}; + for (const day of daysInWeek) { + dayPoints[day] = 0; + dayCompletions[day] = 0; + } + for (const c of memberCompletions) { + const day = (c.date || '').slice(0, 10); + if (dayPoints[day] !== undefined) { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + dayPoints[day] += chore?.type === 'points' ? Number(chore.value) : 0; + dayCompletions[day]++; + } + } + + const bonusPoints = rewardPointsList + .filter((r: any) => r.memberId === m.id) + .reduce((sum: number, r: any) => sum + Number(r.value), 0); + + const weekMoney = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'money' ? Number(chore.value) : 0); + }, 0); + + let bonusMoney = 0; + try { + const cashRewards = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` + }); + bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); + } catch {} + + return { + memberId: m.id, + memberName: m.name, + memberColor: m.color, + pointsEarned: weekPoints + bonusPoints, + moneyEarned: weekMoney + bonusMoney, + choresCompleted: memberCompletions.length, + totalChores: memberAssignments.length, + dayPoints, + dayCompletions + }; + }) + ); + + return { weekStart: ws, daysInWeek, summaries }; +} + +export async function eowPreview(pb: any, famId: string) { + const { payday, tz } = await famMeta(pb, famId); + const ws = weekStart(payday, tz); + const we = periodEnd('weekly', ws); + + const [members, assigned, completions, configs, rewards] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }), + pb + .collection('bonus_configs') + .getFullList({ filter: `famId = '${famId}' && status = 'active'` }) + .catch(() => []), + pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => []) + ]); + + let rewardPointsList: any[] = []; + let rewardCashList: any[] = []; + try { + [rewardPointsList, rewardCashList] = await Promise.all([ + pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` + }), + pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` + }) + ]); + } catch {} + + const assignedList = assigned; + const completionsList = completions; + + const summaries = members.map((m: any) => { + const mc = completionsList.filter((c: any) => c.memberId === m.id); + const weekPoints = mc.reduce((sum: number, c: any) => { + const ch = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (ch?.type === 'points' ? Number(ch.value) : 0); + }, 0); + const weekMoney = mc.reduce((sum: number, c: any) => { + const ch = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (ch?.type === 'money' ? Number(ch.value) : 0); + }, 0); + const bonusPoints = rewardPointsList + .filter((r: any) => r.memberId === m.id) + .reduce((sum: number, r: any) => sum + Number(r.value), 0); + const bonusMoney = rewardCashList + .filter((r: any) => r.memberId === m.id) + .reduce((sum: number, r: any) => sum + Number(r.value), 0); + return { + memberId: m.id, + memberName: m.name || m.username || m.id.slice(0, 6), + memberColor: m.color, + pointsEarned: weekPoints + bonusPoints, + moneyEarned: weekMoney + bonusMoney, + choresCompleted: mc.length, + bonusEarned: bonusPoints + }; + }); + + const predictedRewards: any[] = []; + const existingRewards = rewards; + for (const cfg of configs) { + if (cfg.type === 'manual') continue; + const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : ''; + const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ''; + const periodCompletions = cfg.period + ? completionsList.filter( + (c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd + ) + : completionsList; + const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id); + + const tryEval = (sourceComps: any[]) => { + if (cfg.type === 'threshold') + return sourceComps.reduce((sum: number, c: any) => { + const ch = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (ch?.type === 'points' ? Number(ch.value) : 0); + }, 0); + if (cfg.type === 'count') return sourceComps.length; + return 0; + }; + + if (cfg.target === 'individual') { + const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members; + for (const m of targets) { + if (cfgRewards.some((r: any) => r.memberId === m.id)) continue; + const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id)); + if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue)) + predictedRewards.push({ + config: cfg.name, + memberName: m.name || m.id.slice(0, 6), + type: cfg.rewardType, + value: Number(cfg.rewardValue) || 0, + detail: `${cfg.type} ${current}/${cfg.criteriaValue}` + }); + } + } else if (cfg.target === 'collaborative') { + if (cfgRewards.length) continue; + const allIds = members.map((m: any) => m.id); + const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId)); + const current = tryEval(teamComps); + if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue)) + predictedRewards.push({ + config: cfg.name, + memberName: 'Everyone', + type: cfg.rewardType, + value: Number(cfg.rewardValue) || 0, + detail: `${cfg.type} ${current}/${cfg.criteriaValue}` + }); + } else if (cfg.target === 'competitive') { + if (cfgRewards.length) continue; + const scored = members.map((m: any) => ({ + memberId: m.id, + name: m.name, + current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id)) + })); + const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue)); + const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0); + const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0]; + if (winner) + predictedRewards.push({ + config: cfg.name, + memberName: winner.name, + type: cfg.rewardType, + value: Number(cfg.rewardValue) || 0, + detail: `winner ${winner.current} pts` + }); + } + } + + const nextWeekStart = addDaysStr(ws, 7); + + return { + simulateEow: true, + weekStart: ws, + weekEnd: we, + nextWeekStart, + summaries, + predictedRewards, + completionsThisWeek: completionsList.length + }; +} + +export async function releaseWeek(pb: any, famId: string) { + const { payday, paydayTime, tz } = await famMeta(pb, famId); + const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` }); + const fam = fams?.[0]; + if (!fam) throw new Error('Fam not found'); + + const wsToday = weekStart(payday, tz); + const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz)); + if (Date.now() < target.getTime()) { + return { + settled: false, + notYet: true, + weekStart: wsToday, + target: target.toISOString() + }; + } + + if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday }; + + const members = await pb + .collection('users') + .getFullList({ filter: `famId = '${famId}' && role = 'child'` }); + let cashRewards: any[] = []; + try { + cashRewards = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')` + }); + } catch {} + + const breakdown: any[] = []; + const now = new Date().toISOString(); + const today = now.slice(0, 10); + + for (const m of members) { + const unpaid = cashRewards.filter((r: any) => r.memberId === m.id && r.status !== 'claimed'); + const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0); + if (total > 0) { + for (const r of unpaid) { + if (r.status !== 'requested') { + await pb.collection('rewards').update(r.id, { + status: 'requested', + claimedAt: null, + date: (r.date || '').slice(0, 10) || today + }); + } + } + breakdown.push({ + memberId: m.id, + name: m.name, + total, + rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })) + }); + } + } + + await pb.collection('fams').update(famId, { lastIssued: wsToday }); + return { settled: true, weekStart: wsToday, breakdown }; +} + +export async function completeWeek(pb: any, famId: string) { + const { payday, tz } = await famMeta(pb, famId); + const ws = weekStart(payday, tz); + + await evaluateFam(pb, famId); + + const [members, assigned, completions] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }) + ]); + + let rewardPointsList: any[] = []; + let rewardCashList: any[] = []; + try { + [rewardPointsList, rewardCashList] = await Promise.all([ + pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` + }), + pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` + }) + ]); + } catch {} + + const assignedList = assigned; + const historyRecords: any[] = []; + + for (const m of members) { + const memberCompletions = completions.filter((c: any) => c.memberId === m.id); + + const weekPoints = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + + const weekMoney = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'money' ? Number(chore.value) : 0); + }, 0); + + const bonusPoints = rewardPointsList + .filter((r: any) => r.memberId === m.id) + .reduce((sum: number, r: any) => sum + Number(r.value), 0); + + const bonusMoney = rewardCashList + .filter((r: any) => r.memberId === m.id) + .reduce((sum: number, r: any) => sum + Number(r.value), 0); + + const existing = await pb + .collection('weekly_history') + .getFullList({ filter: `famId = '${famId}' && memberId = '${m.id}' && weekStart = '${ws}'` }); + const recordData = { + famId, + memberId: m.id, + weekStart: ws, + pointsEarned: weekPoints + bonusPoints, + moneyEarned: weekMoney + bonusMoney, + choresCompleted: memberCompletions.length, + bonusEarned: bonusPoints + }; + + if (existing.length > 0) { + await pb.collection('weekly_history').update(existing[0].id, recordData); + } else { + const record = await pb.collection('weekly_history').create(recordData); + historyRecords.push(record); + } + } + + return { + weekStart: ws, + historyRecords, + memberCount: members.length + }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/index.ts b/frontend/src/lib/server/services/index.ts new file mode 100644 index 0000000..ae667f7 --- /dev/null +++ b/frontend/src/lib/server/services/index.ts @@ -0,0 +1,101 @@ +import * as famSvc from './fam'; +import * as choresSvc from './chores'; +import * as completionsSvc from './completions'; +import * as rewardsSvc from './rewards'; +import * as bonusesSvc from './bonuses'; +import * as chatSvc from './chat'; +import * as settingsSvc from './settings'; +import * as crudSvc from './crud'; +import * as debugSvc from './debug'; + +export * from './chat'; +export { famMeta } from './fam'; +export { assertPaydayUnlocked } from './rewards'; + +// Per-feature service binder. `pb` is the acting user's own PocketBase client +// (child or parent token), so PB collection rules enforce famId + role scoping; +// this layer is purely in-process logic grouped by app area for readability. +// `user` supplies the acting user's id/role so member-scoped ops default to it. +export function createServices( + pb: any, + user?: { id?: string; role?: string; name?: string; color?: string } +) { + const uid = user?.id || ''; + + return { + fam: { + meta: (famId: string) => famSvc.famMeta(pb, famId), + get: (famId: string) => famSvc.getFam(pb, famId), + update: (famId: string, body: Record) => famSvc.patchFam(pb, famId, body), + rename: (famId: string, name: string) => famSvc.patchFam(pb, famId, { name }), + updatePayday: (famId: string, payday: number, paydayTime?: string, timezone?: string) => + famSvc.patchFam(pb, famId, { + payday, + ...(paydayTime !== undefined ? { paydayTime } : {}), + ...(timezone !== undefined ? { timezone } : {}) + }), + weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId), + eowPreview: (famId: string) => famSvc.eowPreview(pb, famId), + payday: (famId: string) => famSvc.releaseWeek(pb, famId), + completeWeek: (famId: string) => famSvc.completeWeek(pb, famId), + getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid), + updateProfile: (famId: string, data: Record) => + famSvc.updateProfile(pb, famId, uid, data) + }, + crud: { + list: (resource: string, famId: string) => crudSvc.list(pb, resource, famId), + create: (resource: string, famId: string, data: Record) => + crudSvc.create(pb, resource, famId, data), + update: (resource: string, famId: string, id: string, data: Record) => + crudSvc.update(pb, resource, famId, id, data), + remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id) + }, + chores: { + myChores: (famId: string) => choresSvc.myChores(pb, famId, uid) + }, + completions: { + toggle: (famId: string, body: { assignedChoreId: string; date: string }) => + completionsSvc.toggle(pb, famId, uid, body), + revoke: (famId: string, completionId: string) => completionsSvc.revoke(pb, famId, completionId) + }, + rewards: { + claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id), + approve: (famId: string, id: string) => rewardsSvc.approve(pb, famId, id), + requestAll: (famId: string) => rewardsSvc.requestAll(pb, famId, uid), + issueAll: (famId: string, memberId: string) => rewardsSvc.issueAll(pb, famId, memberId) + }, + bonuses: { + progress: (famId: string) => bonusesSvc.progress(pb, famId), + evaluate: (famId: string) => bonusesSvc.evaluateAll(pb, famId), + trigger: (famId: string, configId: string, memberId?: string) => + bonusesSvc.trigger(pb, famId, configId, memberId), + assign: (famId: string, configId: string, data: Record) => { + const updates: Record = { status: 'active' }; + if (data.target) updates.target = data.target; + if (data.memberId !== undefined) updates.memberId = data.memberId || null; + return pb.collection('bonus_configs').update(configId, updates); + }, + complete: (famId: string, configId: string) => + pb.collection('bonus_configs').update(configId, { status: 'completed' }), + destroy: (famId: string, configId: string) => + pb.collection('bonus_configs').delete(configId) + }, + settings: { + get: (famId: string) => settingsSvc.getSettings(pb, famId), + update: (famId: string, data: Record) => + settingsSvc.updateSettings(pb, famId, data) + }, + chat: { + me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor), + send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) => + chatSvc.send(pb, famId, actor, body), + typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) => + chatSvc.typing(pb, famId, actor, body) + }, + debug: { + generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days) + } + }; +} + +export type Services = ReturnType; \ No newline at end of file diff --git a/frontend/src/lib/server/services/rewards.ts b/frontend/src/lib/server/services/rewards.ts new file mode 100644 index 0000000..a78be3e --- /dev/null +++ b/frontend/src/lib/server/services/rewards.ts @@ -0,0 +1,68 @@ +import { todayInTz, resolveTz } from '@shared/timezone'; +import { famMeta } from './fam'; + +function resolveServerTz(tz?: string): string { + return resolveTz(tz || 'auto'); +} + +export function assertPaydayUnlocked(reward: any, tz?: string) { + if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; + const today = todayInTz(resolveServerTz(tz)); + if (today < reward.settleDate) { + throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`); + } +} + +// Member claim → status 'requested' (pending parent approval). +export async function claim(pb: any, famId: string, id: string) { + const now = new Date().toISOString(); + const { tz } = await famMeta(pb, famId); + const found = await pb + .collection('rewards') + .getFullList({ filter: `famId = '${famId}' && id = '${id}'` }); + const reward = found?.[0]; + if (!reward) throw new Error('Reward not found'); + assertPaydayUnlocked(reward, tz); + return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now }); +} + +// Admin approval → status 'claimed'. +export async function approve(pb: any, famId: string, id: string) { + return pb.collection('rewards').update(id, { + status: 'claimed', + claimedAt: new Date().toISOString() + }); +} + +export async function requestAll(pb: any, famId: string, memberId: string) { + const now = new Date().toISOString(); + const { tz } = await famMeta(pb, famId); + const rewards = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'` + }); + let count = 0; + for (const r of rewards) { + try { + assertPaydayUnlocked(r, tz); + } catch { + continue; + } + await pb.collection('rewards').update(r.id, { status: 'requested', requestedAt: now }); + count++; + } + return { count }; +} + +export async function issueAll(pb: any, famId: string, memberId: string) { + if (!memberId) throw new Error('memberId required'); + const now = new Date().toISOString(); + const rewards = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')` + }); + let count = 0; + for (const r of rewards) { + await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now }); + count++; + } + return { count }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/services/settings.ts b/frontend/src/lib/server/services/settings.ts new file mode 100644 index 0000000..b653651 --- /dev/null +++ b/frontend/src/lib/server/services/settings.ts @@ -0,0 +1,27 @@ +import { todayInTz, resolveTz } from '@shared/timezone'; +import { famMeta } from './fam'; + +function resolveServerTz(tz?: string): string { + return resolveTz(tz || 'auto'); +} + +export async function getSettings(pb: any, famId: string) { + const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` }); + const s = list?.[0] || {}; + return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' }; +} + +export async function updateSettings(pb: any, famId: string, data: Record) { + const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` }); + const existing = list?.[0]; + const patch: Record = {}; + if (data.simulateEow !== undefined) patch.simulateEow = !!data.simulateEow; + if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl); + let s; + if (existing) { + s = Object.keys(patch).length ? await pb.collection('settings').update(existing.id, patch) : existing; + } else { + s = await pb.collection('settings').create({ famId, ...patch }); + } + return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' }; +} \ No newline at end of file diff --git a/frontend/src/lib/server/servicesFor.ts b/frontend/src/lib/server/servicesFor.ts new file mode 100644 index 0000000..ded2b5b --- /dev/null +++ b/frontend/src/lib/server/servicesFor.ts @@ -0,0 +1,11 @@ +import { pbUser } from '$lib/server/pocketbase'; +import { createServices, type Services } from '$lib/server/services'; +import type { RequestEvent } from '@sveltejs/kit'; + +// Build the per-feature service binder for a server request, running as the +// authenticated user's own PB client (session cookie). Shorthand for the +// common `createServices(pbUser(event), event.locals.user)` call used in loads +// and form actions. +export function servicesFor(event: RequestEvent): Services { + return createServices(pbUser(event), event.locals.user || undefined); +} \ No newline at end of file diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index ed04ef7..e4b8de4 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -180,7 +180,7 @@ class ChatStore { // ── Writes via SvelteKit server (forwards session/device auth) ── private async serverChat(payload: Record) { - const res = await fetch('/chat', { + const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 0c11891..865fc05 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -1,42 +1,40 @@ -import { PROXY_URL } from '$app/env/public'; -import { pbAdmin } from '$lib/server/pocketbase'; +import { pbAdmin, createPbClient } from '$lib/server/pocketbase'; +import { createServices, type ChatActor } from '$lib/server/services'; -const HONO_URL = PROXY_URL; - -async function paydayCheck(famId: string, headers: Record) { +async function paydayCheck(famId: string, pbToken: string) { try { - const res = await fetch(`${HONO_URL}/api/fam/${famId}/payday`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers - } - }); // Best-effort: never block render on the payday heartbeat. - await res.json().catch(() => null); + const s = createServices(createPbClient(pbToken)); + await s.fam.payday(famId); } catch {} } -async function resolveChatIdentity( - api: 'admin' | 'member', - opts: { - session?: { famId: string; id: string }; - pbToken?: string; - } -) { +function actorFrom(session: { + famId: string; + id: string; + role: string; + name?: string; + color?: string; +}): ChatActor { + return { + id: session.id, + type: session.role === 'parent' ? 'admin' : 'member', + name: session.name || '', + color: session.color || '#6366f1' + }; +} + +async function resolveChatIdentity(session: { + famId: string; + id: string; + role: string; + name?: string; + color?: string; +}, pbToken: string) { try { - const headers: Record = { 'Content-Type': 'application/json' }; - if (api === 'admin' && opts.session) { - headers['x-session-famid'] = opts.session.famId; - headers['x-session-userid'] = opts.session.id; - } else if (api === 'member' && opts.pbToken) { - headers['Authorization'] = `Bearer ${opts.pbToken}`; - } else { - return null; - } - const res = await fetch(`${HONO_URL}/api/chat/me`, { headers }); - if (!res.ok) return null; - return await res.json(); + const s = createServices(createPbClient(pbToken)); + const actor = actorFrom(session); + return await s.chat.me(session.famId, actor); } catch { return null; } @@ -49,19 +47,12 @@ export async function load(event) { const pbToken = event.cookies.get('pb_token') || ''; let famId = ''; - let chat: { famId: string; actor: any } | null = null; + let chat: { famId: string; actor: ChatActor } | null = null; - if (session && isParent) { + if (session && pbToken) { famId = session.famId; - await paydayCheck(famId, { - 'x-session-famid': session.famId, - 'x-session-userid': session.id - }); - chat = await resolveChatIdentity('admin', { session }); - } else if (role === 'child' && pbToken && session) { - famId = session.famId; - await paydayCheck(famId, { Authorization: `Bearer ${pbToken}` }); - chat = await resolveChatIdentity('member', { pbToken }); + await paydayCheck(famId, pbToken); + chat = await resolveChatIdentity(session, pbToken); } return { @@ -85,4 +76,4 @@ export async function load(event) { ? await pbAdmin.getOne('fams', famId).catch(() => null) : null }; -} +} \ No newline at end of file diff --git a/frontend/src/routes/[fam]/+page.server.ts b/frontend/src/routes/[fam]/+page.server.ts index 006833b..8c51d56 100644 --- a/frontend/src/routes/[fam]/+page.server.ts +++ b/frontend/src/routes/[fam]/+page.server.ts @@ -1,20 +1,22 @@ -import { hono } from '$lib/server/hono'; +import { pbUser } from '$lib/server/pocketbase'; +import { createServices } from '$lib/server/services'; export async function load(event) { const session = event.locals.user; if (!session) return {}; const famId = session.famId; + const s = createServices(pbUser(event), session); try { const [members, templates, assigned, summary] = await Promise.all([ - hono.admin.list(event, 'members', famId), - hono.admin.list(event, 'chore-templates', famId), - hono.admin.list(event, 'assigned-chores', famId), - hono.admin.weeklySummary(event, famId), + s.crud.list('members', famId), + s.crud.list('chore-templates', famId), + s.crud.list('assigned-chores', famId), + s.fam.weeklySummary(famId), ]); return { members, templates, assigned, summary }; } catch { return {}; } -} +} \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts index eaeb6f2..78e72b7 100644 --- a/frontend/src/routes/[fam]/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -1,8 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; -import { hono } from '$lib/server/hono'; -import { PROXY_URL } from '$app/env/public'; - -const HONO_URL = PROXY_URL; +import { pbUser, createPbClient } from '$lib/server/pocketbase'; +import { createServices } from '$lib/server/services'; export async function load(event) { const session = event.locals.user; @@ -17,6 +15,7 @@ export async function load(event) { if (session.name && session.username && session.username !== username) { throw redirect(303, `/${famSlug}/${session.username}`); } + const s = createServices(pbUser(event), session); const [ members, templates, @@ -28,15 +27,15 @@ export async function load(event) { completions, settings ] = await Promise.all([ - hono.admin.list(event, 'members', famId), - hono.admin.list(event, 'chore-templates', famId), - hono.admin.list(event, 'assigned-chores', famId), - hono.admin.weeklySummary(event, famId), - hono.admin.fam(event, famId), - hono.admin.rewards(event, famId), - hono.admin.bonusConfigs(event, famId), - hono.admin.completions(event, famId), - hono.admin.settings(event, famId).catch(() => ({})) + s.crud.list('members', famId), + s.crud.list('chore-templates', famId), + s.crud.list('assigned-chores', famId), + s.fam.weeklySummary(famId), + s.fam.get(famId), + s.crud.list('rewards', famId), + s.crud.list('bonus-configs', famId), + s.crud.list('completions', famId), + s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' })) ]); return { role: 'parent', @@ -88,12 +87,8 @@ export async function load(event) { if (!pbToken || !famId) return empty; try { - const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, { - method: 'POST', - headers: { Authorization: `Bearer ${pbToken}` } - }); - if (!choresRes.ok) return empty; - const chores = await choresRes.json(); + const s = createServices(createPbClient(pbToken), session ?? undefined); + const chores = await s.chores.myChores(famId); return { role: 'child', token: pbToken, @@ -107,7 +102,7 @@ export async function load(event) { completions: chores.completions || [], rewards: chores.rewards || [], bonusConfigs: chores.bonusConfigs || [], - tallies: chores.tallies || {}, + tallies: {} as Record, payday: chores.payday, paydayTime: chores.paydayTime || '18:00', timezone: chores.timezone || 'auto', @@ -125,7 +120,8 @@ export const actions = { const fd = await event.request.formData(); const on = fd.get('on') === 'true'; try { - const result = await hono.admin.updateSettings(event, famId, { simulateEow: on }); + const s = createServices(pbUser(event), event.locals.user); + const result = await s.settings.update(famId, { simulateEow: on }); return { simulateEow: result.simulateEow }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to update settings' }; @@ -136,8 +132,9 @@ export const actions = { if (!event.locals.user) throw redirect(303, '/login'); const famId = event.locals.user.famId; try { - const preview = await hono.admin.eowPreview(event, famId); - await hono.admin.updateSettings(event, famId, { simulateEow: true }); + const s = createServices(pbUser(event), event.locals.user); + const preview = await s.fam.eowPreview(famId); + await s.settings.update(famId, { simulateEow: true }); return { preview, simulateEow: true }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to preview payday' }; @@ -150,7 +147,8 @@ export const actions = { const fd = await event.request.formData(); const rewardId = fd.get('id') as string; try { - const record = await hono.admin.claimReward(event, famId, rewardId); + const s = createServices(pbUser(event), event.locals.user); + const record = await s.rewards.approve(famId, rewardId); return { record }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; @@ -163,7 +161,8 @@ export const actions = { const fd = await event.request.formData(); const memberId = fd.get('memberId') as string; try { - const result = await hono.admin.issueAllRewards(event, famId, memberId); + const s = createServices(pbUser(event), event.locals.user); + const result = await s.rewards.issueAll(famId, memberId); return { count: result.count }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to issue rewards' }; @@ -176,7 +175,8 @@ export const actions = { const fd = await event.request.formData(); const completionId = fd.get('id') as string; try { - await hono.admin.revokeCompletion(event, famId, completionId); + const s = createServices(pbUser(event), event.locals.user); + await s.completions.revoke(famId, completionId); return { revoked: true, id: completionId }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to revoke' }; @@ -191,15 +191,11 @@ export const actions = { const memberId = fd.get('memberId') as string; if (!configId) return { error: 'Config ID required' }; try { - const result = await hono.admin.triggerBonusConfig( - event, - famId, - configId, - memberId || undefined - ); + const s = createServices(pbUser(event), event.locals.user); + const result = await s.bonuses.trigger(famId, configId, memberId || undefined); return { records: result.records || [] }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to trigger' }; } } -}; +}; \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts index 4a6116d..334ef7c 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts @@ -1,15 +1,16 @@ import { fail, redirect } from '@sveltejs/kit'; -import { hono } from '$lib/server/hono'; +import { servicesFor } from '$lib/server/servicesFor'; export async function load(event) { if (!event.locals.user) throw redirect(303, '/login'); const famId = event.locals.user.famId; + const s = servicesFor(event); const [configs, templates, members, progress, rewards] = await Promise.all([ - hono.admin.bonusConfigs(event, famId), - hono.admin.list(event, 'bonus-templates', famId), - hono.admin.list(event, 'members', famId), - hono.admin.bonusConfigProgress(event, famId), - hono.admin.rewards(event, famId) + s.crud.list('bonus-configs', famId), + s.crud.list('bonus-templates', famId), + s.crud.list('members', famId), + s.bonuses.progress(famId), + s.crud.list('rewards', famId) ]); return { configs, templates, members, progress, rewards }; } @@ -34,7 +35,8 @@ export const actions = { if (period !== null) data.period = period; if (data.occurrence === 'once') data.period = ''; try { - const record = await hono.admin.create(event, 'bonus-templates', famId, data); + const s = servicesFor(event); + const record = await s.crud.create('bonus-templates', famId, data); return { record }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' }); @@ -67,7 +69,8 @@ export const actions = { const description = fd.get('description'); if (description) data.description = description; try { - const record = await hono.admin.update(event, 'bonus-templates', famId, id, data); + const s = servicesFor(event); + const record = await s.crud.update('bonus-templates', famId, id, data); return { record }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' }); @@ -80,7 +83,8 @@ export const actions = { const fd = await event.request.formData(); const id = fd.get('id') as string; try { - await hono.admin.remove(event, 'bonus-templates', famId, id); + const s = servicesFor(event); + await s.crud.remove('bonus-templates', famId, id); return { deleted: true }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' }); @@ -112,7 +116,8 @@ export const actions = { const memberId = fd.get('memberId'); if (memberId) data.memberId = memberId; try { - const record = await hono.admin.create(event, 'bonus-configs', famId, data); + const s = servicesFor(event); + const record = await s.crud.create('bonus-configs', famId, data); return { record }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' }); @@ -147,7 +152,8 @@ export const actions = { const memberId = fd.get('memberId'); if (memberId !== null) data.memberId = memberId || null; try { - const record = await hono.admin.update(event, 'bonus-configs', famId, id, data); + const s = servicesFor(event); + const record = await s.crud.update('bonus-configs', famId, id, data); return { record }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' }); @@ -160,7 +166,8 @@ export const actions = { const fd = await event.request.formData(); const id = fd.get('id') as string; try { - await hono.admin.remove(event, 'bonus-configs', famId, id); + const s = servicesFor(event); + await s.crud.remove('bonus-configs', famId, id); return { deleted: true }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' }); @@ -188,7 +195,8 @@ export const actions = { }; if (data.occurrence === 'once') data.period = ''; try { - const record = await hono.admin.create(event, 'bonus-configs', famId, data); + const s = servicesFor(event); + const record = await s.crud.create('bonus-configs', famId, data); return { record }; } catch (e) { return fail(400, { @@ -205,7 +213,8 @@ export const actions = { const target = fd.get('target') as string; const memberId = fd.get('memberId') as string; try { - const record = await hono.admin.assignBonusConfig(event, famId, id, { + const s = servicesFor(event); + const record = await s.bonuses.assign(famId, id, { target, memberId: memberId || null }); @@ -221,7 +230,8 @@ export const actions = { const fd = await event.request.formData(); const id = fd.get('id') as string; try { - const record = await hono.admin.completeBonusConfig(event, famId, id); + const s = servicesFor(event); + const record = await s.bonuses.complete(famId, id); return { record }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' }); @@ -234,7 +244,8 @@ export const actions = { const fd = await event.request.formData(); const id = fd.get('id') as string; try { - await hono.admin.destroyBonusConfig(event, famId, id); + const s = servicesFor(event); + await s.bonuses.destroy(famId, id); return { destroyed: true }; } catch (e) { return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' }); @@ -249,7 +260,8 @@ export const actions = { const currentStatus = fd.get('currentStatus') as string; const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled'; try { - const record = await hono.admin.update(event, 'bonus-configs', famId, id, { + const s = servicesFor(event); + const record = await s.crud.update('bonus-configs', famId, id, { status: newStatus }); return { record }; @@ -262,9 +274,10 @@ export const actions = { if (!event.locals.user) throw redirect(303, '/login'); const famId = event.locals.user.famId; try { - await hono.admin.evaluateBonusConfig(event, famId); + const s = servicesFor(event); + await s.bonuses.evaluate(famId); } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to evaluate' }; } } -}; +}; \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/bonuses/progress.json/+server.ts b/frontend/src/routes/[fam]/[username]/bonuses/progress.json/+server.ts index d0d5a51..687e826 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/progress.json/+server.ts +++ b/frontend/src/routes/[fam]/[username]/bonuses/progress.json/+server.ts @@ -1,12 +1,13 @@ -import { hono } from '$lib/server/hono'; import { json } from '@sveltejs/kit'; +import { servicesFor } from '$lib/server/servicesFor'; export async function GET(event) { const famId = event.params.fam; try { - const progress = await hono.admin.bonusConfigProgress(event, famId); + const s = servicesFor(event); + const progress = await s.bonuses.progress(famId); return json(progress); } catch { return json([]); } -} +} \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/ledger/+page.server.ts b/frontend/src/routes/[fam]/[username]/ledger/+page.server.ts index 66a4e70..4842b31 100644 --- a/frontend/src/routes/[fam]/[username]/ledger/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/ledger/+page.server.ts @@ -1,15 +1,16 @@ import { redirect } from '@sveltejs/kit'; -import { hono } from '$lib/server/hono'; +import { servicesFor } from '$lib/server/servicesFor'; export async function load(event) { if (!event.locals.user) throw redirect(303, '/login'); const famId = event.locals.user.famId; + const s = servicesFor(event); const [rewards, members, assigned, templates, completions] = await Promise.all([ - hono.admin.rewards(event, famId), - hono.admin.list(event, 'members', famId), - hono.admin.list(event, 'assigned-chores', famId), - hono.admin.list(event, 'chore-templates', famId), - hono.admin.completions(event, famId) + s.crud.list('rewards', famId), + s.crud.list('members', famId), + s.crud.list('assigned-chores', famId), + s.crud.list('chore-templates', famId), + s.crud.list('completions', famId) ]); return { rewards, members, assigned, templates, completions }; } @@ -21,10 +22,11 @@ export const actions = { const fd = await event.request.formData(); const rewardId = fd.get('id') as string; try { - const record = await hono.admin.claimReward(event, famId, rewardId); + const s = servicesFor(event); + const record = await s.rewards.approve(famId, rewardId); return { record }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; } } -}; +}; \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts index 31deee8..f6cc9da 100644 --- a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts @@ -1,22 +1,25 @@ import { redirect } from '@sveltejs/kit'; -import { hono } from '$lib/server/hono'; -import { PROXY_URL } from '$app/env/public'; - -const HONO_URL = PROXY_URL; +import { servicesFor } from '$lib/server/servicesFor'; export async function load(event) { const session = event.locals.user; const famSlug = event.params.fam; const username = event.params.username; - // Parent (session auth) — profile lives on the users record - if (session) { - const famId = session.famId; - if (session.username && session.username !== username) { - throw redirect(303, `/${famSlug}/${session.username}/preferences`); - } + if (!session) { + return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; + } + + const famId = session.famId; + if (session.username && session.username !== username) { + throw redirect(303, `/${famSlug}/${session.username}/preferences`); + } + + const s = servicesFor(event); + + if (session.role === 'parent') { try { - const me = await hono.admin.getProfile(event, famId); + const me = await s.fam.getProfile(famId); return { verified: true, token: '', memberId: me.id, famId, memberName: me.name, memberColor: me.color, email: me.email || '', @@ -27,73 +30,49 @@ export async function load(event) { } } - // Child (device token) — profile lives in members - const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; - if (!deviceToken) { - return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; - } - - try { - const res = await fetch(`${HONO_URL}/api/members/verify-token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceToken, famSlug }), - }); - const data = await res.json(); - if (!res.ok || data.name !== username) { - return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; - } - return { - verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId, - memberName: data.name, memberColor: data.color, email: data.email || '', session: false, - }; - } catch { - return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; - } + // Child — profile lives on the users record, read from the session. + return { + verified: true, token: event.cookies.get('pb_token') || '', memberId: session.id, famId, + memberName: session.name || '', memberColor: session.color || '', email: '', + session: true, + }; } export const actions = { update: async (event) => { const session = event.locals.user; + if (!session) return { error: 'Not authenticated' }; + const fd = await event.request.formData(); const name = fd.get('name') as string; const color = fd.get('color') as string; const email = fd.get('email') as string; - if (session) { - const famId = session.famId; + const s = servicesFor(event); + const famId = session.famId; + + if (session.role === 'parent') { try { const data: Record = {}; if (name) data.name = name; if (color) data.color = color; data.email = email || ''; - const me = await hono.admin.updateProfile(event, famId, data); + const me = await s.fam.updateProfile(famId, data); return { success: true, name: me.name, color: me.color, email: me.email }; } catch (e) { return { error: e instanceof Error ? e.message : 'Update failed' }; } } - const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; - const famSlug = event.params.fam; - if (!deviceToken) return { error: 'Not authenticated' }; - + // Child — update own users record via their PB token. try { - const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceToken, famSlug }), - }); - const verify = await verifyRes.json(); - if (!verifyRes.ok) return { error: 'Verification failed' }; - const res = await fetch(`${HONO_URL}/api/members/me`, { - method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, color }), - }); - const data = await res.json(); - if (!res.ok) return { error: data.error || 'Update failed' }; - return { success: true, name: data.name, color: data.color }; + const data: Record = {}; + if (name) data.name = name; + if (color) data.color = color; + const me = await s.fam.updateProfile(famId, data); + return { success: true, name: me.name, color: me.color, email: '' }; } catch (e) { return { error: e instanceof Error ? e.message : 'Update failed' }; } }, -}; +}; \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts index 3416769..67695da 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts @@ -2,7 +2,7 @@ import { redirect } 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 { servicesFor } from '$lib/server/servicesFor'; import { issueAccess, createChild } from '$lib/server/member-otp'; import { slugify } from '@shared/slugify'; @@ -124,11 +124,12 @@ export const actions = { return { deletedChoreIds: deletedIds }; }, - // Compute endpoints — still proxied to Hono. + // Compute endpoints — in-process. completeWeek: async (event: RequestEvent) => { const famId = famIdOf(event); try { - const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`); + const s = servicesFor(event); + const result = await s.fam.completeWeek(famId); return { success: true, result }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to complete week' }; @@ -140,7 +141,8 @@ export const actions = { const fd = await event.request.formData(); const days = parseInt((fd.get('days') as string) || '7', 10); try { - const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days }); + const s = servicesFor(event); + const result = await s.debug.generateData(famId, days); return { success: true, result }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to generate data' }; diff --git a/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts b/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts new file mode 100644 index 0000000..05d6af3 --- /dev/null +++ b/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices } from '$lib/server/services'; + +export async function POST(event: RequestEvent) { + const { pb, famId, userId, role } = actingClient(event); + if (famId !== event.params.famId) { + return json({ error: 'famId mismatch' }, { status: 403 }); + } + const body = await event.request.json().catch(() => ({})); + try { + const s = createServices(pb, { id: userId, role }); + const record = await s.crud.create('assigned-chores', famId, body); + return json(record); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'create failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts b/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts new file mode 100644 index 0000000..31943a3 --- /dev/null +++ b/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices } from '$lib/server/services'; + +export async function DELETE(event: RequestEvent) { + const { pb, famId, userId, role } = actingClient(event); + if (famId !== event.params.famId) { + return json({ error: 'famId mismatch' }, { status: 403 }); + } + const id = event.params.id!; + try { + const s = createServices(pb, { id: userId, role }); + await s.crud.remove('assigned-chores', famId, id); + return json({ ok: true }); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'delete failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/chat/+server.ts b/frontend/src/routes/api/chat/+server.ts new file mode 100644 index 0000000..ae5134a --- /dev/null +++ b/frontend/src/routes/api/chat/+server.ts @@ -0,0 +1,41 @@ +import { json } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices, type ChatActor } from '$lib/server/services'; +import type { RequestEvent } from '@sveltejs/kit'; + +type Body = { + action: 'send' | 'typing'; + famId?: string; + content?: string; + clientId?: string; + typing?: boolean; +}; + +export async function POST(event: RequestEvent) { + const body = (await event.request.json().catch(() => null)) as Body | null; + if (!body || !body.action) return json({ error: 'missing action' }, { status: 400 }); + + const { pb, famId: sessionFamId, userId, role, name, color } = actingClient(event); + const actor: ChatActor = { + id: userId, + type: role === 'parent' ? 'admin' : 'member', + name, + color + }; + const famId = body.famId || sessionFamId || ''; + if (!famId) return json({ error: 'famId required' }, { status: 400 }); + + try { + const s = createServices(pb, { id: userId, role }); + const data = + body.action === 'typing' + ? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) }) + : await s.chat.send(famId, actor, { + content: body.content || '', + clientId: body.clientId || '' + }); + return json(data); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'chat request failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/completions/toggle/+server.ts b/frontend/src/routes/api/completions/toggle/+server.ts new file mode 100644 index 0000000..4bc2165 --- /dev/null +++ b/frontend/src/routes/api/completions/toggle/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices } from '$lib/server/services'; + +export async function POST(event: RequestEvent) { + const { pb, famId, userId, role } = actingClient(event); + const body = await event.request.json().catch(() => ({})); + try { + const s = createServices(pb, { id: userId, role }); + return json(await s.completions.toggle(famId, body)); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/fam/[famId]/payday/+server.ts b/frontend/src/routes/api/fam/[famId]/payday/+server.ts new file mode 100644 index 0000000..7e4f530 --- /dev/null +++ b/frontend/src/routes/api/fam/[famId]/payday/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices } from '$lib/server/services'; + +export async function POST(event: RequestEvent) { + const { pb, famId, userId, role } = actingClient(event); + try { + const s = createServices(pb, { id: userId, role }); + return json(await s.fam.payday(famId)); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'payday failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/members/me/+server.ts b/frontend/src/routes/api/members/me/+server.ts new file mode 100644 index 0000000..b0ae416 --- /dev/null +++ b/frontend/src/routes/api/members/me/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices } from '$lib/server/services'; + +export async function PATCH(event: RequestEvent) { + const { pb, famId, userId, role } = actingClient(event); + const body = await event.request.json().catch(() => ({})); + try { + const s = createServices(pb, { id: userId, role }); + return json(await s.fam.updateProfile(famId, body)); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'update failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts b/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts new file mode 100644 index 0000000..de72fb0 --- /dev/null +++ b/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { actingClient } from '$lib/server/routeAuth'; +import { createServices } from '$lib/server/services'; + +export async function POST(event: RequestEvent) { + const { pb, famId, userId, role } = actingClient(event); + const id = event.params.id!; + try { + const s = createServices(pb, { id: userId, role }); + return json(await s.rewards.claim(famId, id)); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/chat/+server.ts b/frontend/src/routes/chat/+server.ts deleted file mode 100644 index f147530..0000000 --- a/frontend/src/routes/chat/+server.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { PROXY_URL } from '$app/env/public'; -import type { RequestEvent } from '@sveltejs/kit'; - -const HONO_URL = PROXY_URL; - -type Body = { - action: 'send' | 'typing'; - famId?: string; - content?: string; - clientId?: string; - typing?: boolean; -}; - -export async function POST(event: RequestEvent) { - const body = (await event.request.json().catch(() => null)) as Body | null; - if (!body || !body.action) return json({ error: 'missing action' }, 400); - - const session = event.locals.user; - const pbToken = event.cookies.get('pb_token') || ''; - - const headers: Record = { 'Content-Type': 'application/json' }; - let famId = body.famId || ''; - - if (session?.role === 'parent' && session?.famId && session?.id) { - // Admin (parent) — trust the verified session server-side. - headers['x-session-famid'] = session.famId; - 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; - } else { - return json({ error: 'Unauthorized' }, 401); - } - - if (!famId) return json({ error: 'famId required' }, 400); - - const path = body.action === 'typing' ? `/api/chat/${famId}/typing` : `/api/chat/${famId}/messages`; - const payload = - body.action === 'typing' - ? { typing: Boolean(body.typing) } - : { content: body.content || '', clientId: body.clientId || '' }; - - try { - const res = await fetch(`${HONO_URL}${path}`, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) return json({ error: data.error || 'chat request failed' }, res.status); - return json(data); - } catch { - return json({ error: 'chat request failed' }, 502); - } -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5463cc3..74a985c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -29,15 +29,8 @@ export default defineConfig(() => { allow: ['.', './node_modules', '../node_modules'] }, // Dev only: allow access via any host/LAN IP without hardcoding it. - allowedHosts: true, - port: 2080, - proxy: { - '/api': { - // The vite dev server and Hono proxy run on the same host. - target: `http://127.0.0.1:3456`, - changeOrigin: true - } - } + allowedHosts: true as true, + port: 2080 } }; }); diff --git a/package.json b/package.json index 7a06774..b25665e 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "private": true, "packageManager": "pnpm@10.30.3", "scripts": { - "dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev", + "dev": "pnpm --filter frontend dev", "start": "pnpm dev", - "build": "pnpm -r build" + "build": "pnpm --filter frontend build" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bf5a91..9343500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,28 +64,6 @@ importers: specifier: 8.0.16 version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) - proxy: - dependencies: - '@hono/node-server': - specifier: ^1.13.0 - version: 1.19.14(hono@4.12.27) - hono: - specifier: ^4.7.0 - version: 4.12.27 - devDependencies: - '@types/node': - specifier: ^26.0.0 - version: 26.0.0 - esbuild: - specifier: ^0.28.1 - version: 0.28.1 - tsx: - specifier: ^4.19.0 - version: 4.22.4 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - packages: '@emnapi/core@1.10.0': @@ -265,12 +243,6 @@ packages: '@hiseb/confetti@2.2.0': resolution: {integrity: sha512-iCcTe2AS2Mnj7f2BGPnOetjnX+Qs1jgnKU0GSYyQHFB42psio0EpgxmPXOXf2wcCGBH/W+1G2Ecl4hcbsin1Kg==} - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -761,10 +733,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - hono@4.12.27: - resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} - engines: {node: '>=16.9.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1062,11 +1030,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1261,10 +1224,6 @@ snapshots: '@hiseb/confetti@2.2.0': {} - '@hono/node-server@1.19.14(hono@4.12.27)': - dependencies: - hono: 4.12.27 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1609,6 +1568,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.1 '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + optional: true esm-env@1.2.2: {} @@ -1632,8 +1592,6 @@ snapshots: graceful-fs@4.2.11: {} - hono@4.12.27: {} - is-fullwidth-code-point@3.0.0: {} is-reference@3.0.3: @@ -1879,8 +1837,7 @@ snapshots: esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 - - typescript@5.9.3: {} + optional: true typescript@6.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1d54926..23a078c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ injectWorkspacePackages: true packages: - "frontend" - - "proxy" onlyBuiltDependencies: - "@tailwindcss/oxide" - - esbuild + - esbuild \ No newline at end of file diff --git a/proxy/.gitignore b/proxy/.gitignore deleted file mode 100644 index 9b1c8b1..0000000 --- a/proxy/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/dist diff --git a/proxy/package.json b/proxy/package.json deleted file mode 100644 index 2320deb..0000000 --- a/proxy/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "proxy", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch --env-file-if-exists=../.env src/index.ts", - "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --alias:@shared=../shared", - "start": "node dist/index.js", - "seed": "tsx --env-file-if-exists=../.env scripts/seed.ts" - }, - "dependencies": { - "@hono/node-server": "^1.13.0", - "hono": "^4.7.0" - }, - "devDependencies": { - "@types/node": "^26.0.0", - "esbuild": "^0.28.1", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts deleted file mode 100644 index 9c0f470..0000000 --- a/proxy/scripts/seed.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - SCHEMA_PLAN, - type CollectionDef, -} from "@shared/pb/schema.ts"; -import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "../src/env.ts"; - -async function getSuperadminToken(): Promise { - 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(`Auth failed: ${JSON.stringify(data)}`); - return data.token; -} - -async function createCollection( - token: string, - col: CollectionDef, -): Promise { - const existing = await fetch( - `${PB_ENDPOINT}/api/collections?filter=name='${col.name}'`, - { headers: { Authorization: `Bearer ${token}` } }, - ); - const existingData = await existing.json(); - if (existingData?.items?.length > 0) { - console.log(` ↳ Already exists: ${col.name}`); - return existingData.items[0].id; - } - - const res = await fetch(`${PB_ENDPOINT}/api/collections`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(col), - }); - const data = await res.json(); - if (!res.ok) - throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); - console.log(` ✓ Created: ${col.name}`); - return data.id; -} - -async function main() { - console.log("Connecting to PB at", PB_ENDPOINT); - const token = await getSuperadminToken(); - console.log("Authenticated as superadmin\n"); - - const ids: Record = {}; - for (const entry of SCHEMA_PLAN) { - const id = await createCollection(token, entry.build(ids)); - if (id) ids[entry.name] = id; - } - - console.log("\n✅ All collections created successfully"); - console.log("Collection IDs:", ids); -} - -main().catch((err) => { - console.error("Seed failed:", err); - process.exit(1); -}); diff --git a/proxy/scripts/test-admin.ts b/proxy/scripts/test-admin.ts deleted file mode 100644 index df6545b..0000000 --- a/proxy/scripts/test-admin.ts +++ /dev/null @@ -1,99 +0,0 @@ -const HONO = "http://192.168.1.225:3456"; - -async function main() { - // Signup - const signup = await fetch(`${HONO}/api/admin/signup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "admin2@test.com", password: "password1234", famName: "Admin Test" }), - }); - const s = await signup.json(); - console.log("Signup:", s.famId, s.famSlug); - const famId = s.famId; - - // Test admin authenticated calls - const headers = { - "x-session-famid": famId, - "x-session-role": "admin", - "x-session-userid": s.userId, - "Content-Type": "application/json", - }; - - // Create a chore template - console.log("\nCreate template..."); - const tmpl = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { - method: "POST", headers, body: JSON.stringify({ - name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 10, - }), - }); - const t = await tmpl.json(); - console.log("Template:", t.id, t.name); - - // List templates - console.log("\nList templates..."); - const list = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { headers }); - console.log("Templates:", (await list.json()).length); - - // Create a member - console.log("\nCreate member..."); - const mem = await fetch(`${HONO}/api/admin/${famId}/members`, { - method: "POST", headers, body: JSON.stringify({ name: "Kid", color: "#6366f1" }), - }); - const m = await mem.json(); - console.log("Member:", m.id, m.name); - - // Assign chore - console.log("\nAssign chore..."); - const assign = await fetch(`${HONO}/api/admin/${famId}/assigned-chores`, { - method: "POST", headers, body: JSON.stringify({ - memberId: m.id, templateId: t.id, frequency: "daily", type: "points", value: 10, - }), - }); - const a = await assign.json(); - console.log("Assigned:", a.id); - - // Test device token auth - console.log("\nTest completion toggle with device token..."); - const devHeaders = { - "x-device-token": "dev-token-test", - "x-device-famid": famId, - "Content-Type": "application/json", - }; - // First need to join with this device token - const join = await fetch(`${HONO}/api/members/join`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inviteCode: s.famSlug, ...(await (await fetch(`${HONO}/api/admin/${famId}/members`, { headers })).json()).length > 1 ? {} : { name: "Test", deviceToken: "dev-token-test" } }), - }); - // Actually, just use the member we already created but we can't use device token with it since it has no token - // Let's join a new one - console.log("Joining with device token..."); - const j = await fetch(`${HONO}/api/members/join`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inviteCode: "TEST", name: "Test Kid", deviceToken: "dev-token-test" }), - }); - const joinData = await j.json(); - console.log("Join result:", JSON.stringify(joinData)); - - if (joinData.famId) { - // Toggle completion - console.log("\nToggle completion..."); - const toggle = await fetch(`${HONO}/api/completions/toggle`, { - method: "POST", - headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" }, - body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }), - }); - console.log("Toggle:", await toggle.json()); - - // Toggle again (should undo) - const toggle2 = await fetch(`${HONO}/api/completions/toggle`, { - method: "POST", - headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" }, - body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }), - }); - console.log("Toggle undo:", await toggle2.json()); - } - - console.log("\n✅ All admin tests passed"); -} - -main().catch(console.error); diff --git a/proxy/scripts/test-auth.ts b/proxy/scripts/test-auth.ts deleted file mode 100644 index 3645911..0000000 --- a/proxy/scripts/test-auth.ts +++ /dev/null @@ -1,63 +0,0 @@ -const HONO = "http://192.168.1.225:3456"; - -async function main() { - // 1. Signup - console.log("=== Signup ==="); - const signup = await fetch(`${HONO}/api/admin/signup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "test@fam.com", password: "password123", famName: "Test Fam" }), - }); - const signupData = await signup.json(); - console.log("Signup:", JSON.stringify(signupData, null, 2)); - - // 2. Login - console.log("\n=== Login ==="); - const login = await fetch(`${HONO}/api/admin/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "test@fam.com", password: "password123" }), - }); - const loginData = await login.json(); - console.log("Login:", JSON.stringify(loginData, null, 2)); - - // 3. Get invite code from fams directly via PB - const pbTokenRes = await fetch("http://192.168.1.225:8090/api/collections/_superusers/auth-with-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: "debug@famchamp.dev", password: "debug123" }), - }); - const pbTokenData = await pbTokenRes.json(); - const pbSuperToken = pbTokenData.token; - - const famsRes = await fetch("http://192.168.1.225:8090/api/collections/fams/records?sort=-created", { - headers: { Authorization: `Bearer ${pbSuperToken}` }, - }); - const famsData = await famsRes.json(); - const fam = famsData.items[0]; - console.log("\n=== Fam ==="); - console.log("Fam:", JSON.stringify(fam, null, 2)); - console.log("Invite code:", fam.inviteCode); - - // 4. Join as member - console.log("\n=== Join ==="); - const join = await fetch(`${HONO}/api/members/join`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inviteCode: fam.inviteCode, name: "Kid", deviceToken: "dev-token-xyz" }), - }); - const joinData = await join.json(); - console.log("Join:", JSON.stringify(joinData, null, 2)); - - // 5. Verify member - console.log("\n=== Verify ==="); - const verify = await fetch(`${HONO}/api/members/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ famId: fam.id, deviceToken: "dev-token-xyz" }), - }); - const verifyData = await verify.json(); - console.log("Verify:", JSON.stringify(verifyData, null, 2)); -} - -main().catch(console.error); diff --git a/proxy/src/env.ts b/proxy/src/env.ts deleted file mode 100644 index 435d629..0000000 --- a/proxy/src/env.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Runtime env for the proxy. Same dev/prod split as the frontend: dev connects -// to the dev machine's PocketBase at SERVER_IP, prod connects to the -// container-internal loopback PB. Ports come from config.ts (single source). -// -// Env loading: dev uses `tsx --env-file=../.env` (see package.json) like vite -// does for the frontend; prod (docker) injects env via compose and has no .env, -// so SERVER_IP is unset → loopback below. -const SERVER_IP = process.env.SERVER_IP; - -// PB_ENDPOINT can be overridden explicitly (used for migration step-through -// against a throwaway PB on another port). Defaults to the dev/prod split. -export const PB_ENDPOINT = - process.env.PB_ENDPOINT || - (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_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"; diff --git a/proxy/src/index.ts b/proxy/src/index.ts deleted file mode 100644 index 3b4b416..0000000 --- a/proxy/src/index.ts +++ /dev/null @@ -1,2338 +0,0 @@ -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { pb } from "./pb.ts"; -import { migrate } from "./migrate.ts"; -import { PROXY_PORT } from "@shared/config.ts"; -import { PB_ENDPOINT, MEMBER_SECRET } from "./env.ts"; -import { - weekStart as tzWeekStart, - addDaysStr, - todayInTz, - wallClockToUtc, - resolveTz, - nextPaydayAfter as nextPaydayAfterTz, - periodWindow, -} from "@shared/timezone.ts"; -import { slugify, handle, famUsername } from "@shared/slugify.ts"; - -const app = new Hono(); - -app.get("/api/health", (c) => c.json({ status: "ok" })); - -function handleError(c: any, err: unknown) { - const msg = err instanceof Error ? err.message : "Internal error"; - return c.json({ error: msg }, 500); -} - -// ── Helpers ───────────────────────────────────────────── - -function resolveServerTz(tz?: string): string { - return resolveTz(tz || "auto"); -} - -function weekStart(payday: number = 1, tz?: string): string { - return tzWeekStart(payday, resolveServerTz(tz)); -} - -function addDays(dateStr: string, days: number): string { - return addDaysStr(dateStr, days); -} - -function monthStart(month?: string): string { - if (month) return `${month}-01`; - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; -} - -function monthEnd(month?: string): string { - if (!month) { - const d = new Date(); - month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; - } - const [y, m] = month.split("-").map(Number); - const lastDay = new Date(y, m, 0).getDate(); - return `${month}-${String(lastDay).padStart(2, "0")}`; -} - -function periodStart(period: string, payday?: number, tz?: string): string { - if (period === "daily") return todayInTz(resolveServerTz(tz)); - if (period === "weekly") return weekStart(payday, tz); - if (period === "monthly") return monthStart(); - return weekStart(payday, tz); -} - -function periodEnd(period: string, start: string): string { - if (period === "daily") return start; - if (period === "weekly") { - return addDaysStr(start, 6); - } - if (period === "monthly") { - const m = start.slice(0, 7); - return monthEnd(m); - } - return start; -} - -function nextPaydayAfter(dateStr: string, payday: number, tz?: string): string { - return nextPaydayAfterTz(dateStr, payday, resolveServerTz(tz)); -} - -// Rewards from weekly/monthly bonus configs are claimable only on payday. -// Stamp the reward with a settleDate = the next payday after the period ends. -function claimableStamp(cfg: any, payday: number, tz?: string) { - if (cfg.period !== "weekly" && cfg.period !== "monthly") { - return { claimable: "immediate", settleDate: "" }; - } - const tzR = resolveServerTz(tz); - const now = todayInTz(tzR); - const start = - cfg.period === "monthly" ? `${now.slice(0, 7)}-01` : weekStart(payday, tzR); - const end = periodEnd(cfg.period, start); - return { claimable: "payday", settleDate: nextPaydayAfter(end, payday, tzR) }; -} - -// Payday-gated rewards can't be claimed until their settleDate (the payday). -function assertPaydayUnlocked(reward: any, tz?: string) { - if (!reward || reward.claimable !== "payday" || !reward.settleDate) return; - const today = todayInTz(resolveServerTz(tz)); - if (today < reward.settleDate) { - throw new Error( - `This bonus pays out on payday (${reward.settleDate}) — hang tight!`, - ); - } -} - -async function getFamPayday(famId: string): Promise { - try { - const fam = await pb.getList("fams", `id = '${famId}'`); - const p = fam.items?.[0]?.payday; - return p !== undefined && p !== null ? Number(p) : 1; - } catch { - return 1; - } -} - -async function getFamPaydayTime(famId: string): Promise { - try { - const fam = await pb.getList("fams", `id = '${famId}'`); - const t = fam.items?.[0]?.paydayTime; - return t || "18:00"; - } catch { - return "18:00"; - } -} - -async function getFamTimezone(famId: string): Promise { - try { - const fam = await pb.getList("fams", `id = '${famId}'`); - return resolveTz(fam.items?.[0]?.timezone); - } catch { - return resolveTz("auto"); - } -} - -// ── Middleware ───────────────────────────────────────────── - -async function requireAdmin(c: any, next: any) { - const famId = c.req.header("x-session-famid"); - const userId = c.req.header("x-session-userid"); - if (!famId || !userId) { - return c.json({ error: "Unauthorized" }, 401); - } - const parents = await pb.getList( - "users", - `famId = '${famId}' && role = 'parent' && id = '${userId}'`, - ); - if (!parents.items?.length) { - return c.json({ error: "Unauthorized" }, 401); - } - c.set("famId", famId); - return next(); -} - -// Member (child) auth: validates the user's pb_token JWT and identifies the -// child. Children live in the `users` auth collection (role='child'); memberId -// (the child-scoped foreign key) is now the users record id. auth-refresh both -// cryptographically validates the token and returns the record in one call. -async function requireMember(c: any, next: any) { - const auth = c.req.header("Authorization") || ""; - 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 res = await fetch(`${PB_ENDPOINT}/api/collections/users/auth-refresh`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) return c.json({ error: "Unauthorized" }, 401); - const body = await res.json().catch(() => ({})); - const record = body?.record; - if (!record || record.role !== "child") { - return c.json({ error: "Forbidden" }, 403); - } - c.set("famId", record.famId); - c.set("memberId", record.id); - c.set("user", record); - return next(); -} - -// ── Auth routes ─────────────────────────────────────────── - -app.post("/api/admin/signup", async (c) => { - try { - const { email, password, famName, parentName } = await c.req.json(); - if (!email || !password || !famName) { - return c.json({ error: "email, password, famName required" }, 400); - } - const slug = slugify(famName); - const user = await pb.createUser(email, password); - const fam = await pb.create("fams", { - name: famName, - slug, - featureFlags: {}, - timezone: "auto", - }); - const adminName = parentName || email.split("@")[0]; - await pb.update("users", user.id, { - name: adminName, - color: "#6366f1", - }); - await pb.create("settings", { famId: fam.id }); - const authResult = await pb.authWithPassword(email, password); - return c.json({ - famId: fam.id, - famSlug: slug, - userId: user.id, - token: authResult.token, - memberId: user.id, - memberName: adminName, - memberColor: "#6366f1", - role: "parent", - }); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/admin/login", async (c) => { - try { - const { email, password } = await c.req.json(); - if (!email || !password) - return c.json({ error: "email, password required" }, 400); - const authResult = await pb.authWithPassword(email, password); - const userId = authResult.record.id; - const parents = await pb.getList( - "users", - `famId != '' && role = 'parent' && id = '${userId}'`, - ); - 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]; - if (!fam) return c.json({ error: "Fam not found" }, 404); - const defaultName = email.split("@")[0]; - const parentPatch: Record = { - name: defaultName, - color: "#6366f1", - }; - if (!parent.email) parentPatch.email = email; - await pb.update("users", parent.id, parentPatch); - return c.json({ - famId: fam.id, - famSlug: fam.slug, - userId, - token: authResult.token, - memberId: parent.id, - memberName: defaultName, - memberColor: "#6366f1", - role: "parent", - }); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/members/me", requireMember, async (c) => { - try { - const body = await c.req.json(); - const memberId = c.get("memberId"); - const update: Record = {}; - if (body.name) update.name = body.name; - if (body.color) update.color = body.color; - if (!Object.keys(update).length) - return c.json({ error: "Nothing to update" }, 400); - const record = await pb.update("users", memberId, update); - return c.json({ id: record.id, name: record.name, color: record.color }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin CRUD: Chore Templates ────────────────────────── - -app.get("/api/admin/:famId/chore-templates", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("chore_templates", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/admin/:famId/chore-templates", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - const record = await pb.create("chore_templates", { famId, ...body }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - const body = await c.req.json(); - const record = await pb.update("chore_templates", id, body); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.delete("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - await pb.delete("chore_templates", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin CRUD: Members ────────────────────────────────── - -app.get("/api/admin/:famId/members", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList( - "users", - `famId = '${famId}' && role = 'child'`, - ); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/admin/:famId/members", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - 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); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => { - try { - const { famId, id } = c.req.param(); - const body = await c.req.json(); - const record = await pb.update("users", id, body); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.delete("/api/admin/:famId/members/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - await pb.delete("users", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin CRUD: Assigned Chores ────────────────────────── - -app.get("/api/admin/:famId/assigned-chores", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("assigned_chores", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/admin/:famId/assigned-chores", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - const record = await pb.create("assigned_chores", { famId, ...body }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - const body = await c.req.json(); - const record = await pb.update("assigned_chores", id, body); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.delete("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => { - try { - const { famId, id } = c.req.param(); - const comps = await pb.getList("completions", `famId = '${famId}'`); - const toDelete = (comps.items || []).filter( - (c: any) => c.assignedChoreId === id, - ); - for (const comp of toDelete) { - await pb.delete("completions", comp.id); - } - await pb.delete("assigned_chores", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Completions ──────────────────────────── - -app.get("/api/admin/:famId/completions", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("completions", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin CRUD: Seasons ────────────────────────── - -app.get("/api/admin/:famId/seasons", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("seasons", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/admin/:famId/seasons", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - const record = await pb.create("seasons", { famId, ...body }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/seasons/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - const body = await c.req.json(); - const record = await pb.update("seasons", id, body); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.delete("/api/admin/:famId/seasons/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - await pb.delete("seasons", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Regenerate invite code ──────────────────────── - -app.get("/api/admin/:famId/fam", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const fams = await pb.getList("fams", `id = '${famId}'`); - const fam = fams.items?.[0]; - if (!fam) return c.json({ error: "Fam not found" }, 404); - return c.json({ - name: fam.name, - slug: fam.slug, - payday: fam.payday, - paydayTime: fam.paydayTime || "18:00", - timezone: fam.timezone || "auto", - }); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - if (body.name !== undefined) { - const name = body.name; - if (!name) return c.json({ error: "name required" }, 400); - const slug = slugify(name); - const record = await pb.update("fams", famId, { name, slug }); - return c.json({ - name: record.name, - slug: record.slug, - payday: record.payday, - }); - } - if ( - body.payday !== undefined || - body.paydayTime !== undefined || - body.timezone !== undefined - ) { - const patch: Record = {}; - if (body.payday !== undefined) { - const payday = Number(body.payday); - if (payday < 0 || payday > 6 || !Number.isInteger(payday)) - return c.json({ error: "payday must be 0-6" }, 400); - patch.payday = payday; - } - if (body.paydayTime !== undefined) { - const paydayTime = String(body.paydayTime); - if (!/^\d{2}:\d{2}$/.test(paydayTime)) - return c.json({ error: "paydayTime must be HH:MM" }, 400); - patch.paydayTime = paydayTime; - } - if (body.timezone !== undefined) { - const timezone = String(body.timezone); - if ( - timezone !== "auto" && - !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone) - ) - return c.json( - { error: "timezone must be an IANA name or 'auto'" }, - 400, - ); - patch.timezone = timezone; - } - const record = await pb.update("fams", famId, patch); - return c.json({ - payday: record.payday, - paydayTime: record.paydayTime, - timezone: record.timezone, - }); - } - return c.json({ error: "no valid fields" }, 400); - } catch (err) { - return handleError(c, err); - } -}); - -app.get("/api/admin/:famId/verify", requireAdmin, async (c) => { - return c.json({ verified: true }); -}); - -// ── Admin: Weekly Summary ────────────────────────────── - -app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - const ws = weekStart(payday, tz); - const [members, assigned, completions] = await Promise.all([ - pb.getList("users", `famId = '${famId}' && role = 'child'`), - pb.getList("assigned_chores", `famId = '${famId}'`), - pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), - ]); - - let rewardPointsList: any[] = []; - try { - const pointRewards = await pb.getList( - "rewards", - `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`, - ); - rewardPointsList = pointRewards.items; - } catch {} // schema may not have rewardType/status yet - - const assignedList = assigned.items; - const completionsList = completions.items; - - const daysInWeek: string[] = []; - { - const d = new Date(ws + "T00:00:00Z"); - for (let i = 0; i < 7; i++) { - daysInWeek.push(d.toISOString().slice(0, 10)); - d.setDate(d.getDate() + 1); - } - } - - const summaries = await Promise.all( - members.items.map(async (m: any) => { - const memberAssignments = assignedList.filter( - (a: any) => a.memberId === m.id, - ); - const memberCompletions = completionsList.filter( - (c: any) => c.memberId === m.id, - ); - - const weekPoints = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - - const dayPoints: Record = {}; - const dayCompletions: Record = {}; - for (const day of daysInWeek) { - dayPoints[day] = 0; - dayCompletions[day] = 0; - } - for (const c of memberCompletions) { - const day = (c.date || "").slice(0, 10); - if (dayPoints[day] !== undefined) { - const chore = assignedList.find( - (a: any) => a.id === c.assignedChoreId, - ); - dayPoints[day] += - chore?.type === "points" ? Number(chore.value) : 0; - dayCompletions[day]++; - } - } - - const bonusPoints = rewardPointsList - .filter((r: any) => r.memberId === m.id) - .reduce((sum: number, r: any) => sum + Number(r.value), 0); - - const weekMoney = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "money" ? Number(chore.value) : 0); - }, 0); - - // Include claimed cash rewards in money earned (this week only) - let bonusMoney = 0; - try { - const cashRewards = await pb.getList( - "rewards", - `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`, - ); - bonusMoney = cashRewards.items.reduce( - (sum: number, r: any) => sum + Number(r.value), - 0, - ); - } catch {} - - return { - memberId: m.id, - memberName: m.name, - memberColor: m.color, - pointsEarned: weekPoints + bonusPoints, - moneyEarned: weekMoney + bonusMoney, - choresCompleted: memberCompletions.length, - totalChores: memberAssignments.length, - dayPoints, - dayCompletions, - }; - }), - ); - - return c.json({ weekStart: ws, daysInWeek, summaries }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Family settings (debug flag) ──────────────── - -async function getFamSettings(famId: string): Promise { - try { - return ( - (await pb.getList("settings", `famId = '${famId}'`)).items?.[0] || {} - ); - } catch { - return {}; - } -} - -app.get("/api/admin/:famId/settings", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const s = await getFamSettings(famId); - return c.json({ - simulateEow: !!s.simulateEow, - webhookUrl: s.webhookUrl || "", - }); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - let s = await getFamSettings(famId); - const patch: Record = {}; - if (body.simulateEow !== undefined) patch.simulateEow = !!body.simulateEow; - if (body.webhookUrl !== undefined) - patch.webhookUrl = String(body.webhookUrl); - if (!s.id) { - s = await pb.create("settings", { famId, ...patch }); - } else if (Object.keys(patch).length) { - s = await pb.update("settings", s.id, patch); - } - return c.json({ - simulateEow: !!s.simulateEow, - webhookUrl: s.webhookUrl || "", - }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: EOW rollover preview (READ-ONLY) ────────── - -app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - const ws = weekStart(payday, tz); - const we = periodEnd("weekly", ws); - - const [members, assigned, completions, configs, rewards] = - await Promise.all([ - pb.getList("users", `famId = '${famId}' && role = 'child'`), - pb.getList("assigned_chores", `famId = '${famId}'`), - pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), - pb - .getList("bonus_configs", `famId = '${famId}' && status = 'active'`) - .catch(() => ({ items: [] })), - pb - .getList("rewards", `famId = '${famId}'`) - .catch(() => ({ items: [] })), - ]); - - let rewardPointsList: any[] = []; - let rewardCashList: any[] = []; - try { - const [pw, cw] = await Promise.all([ - pb.getList( - "rewards", - `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`, - ), - pb.getList( - "rewards", - `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`, - ), - ]); - rewardPointsList = pw.items; - rewardCashList = cw.items; - } catch {} - - const assignedList = assigned.items; - const completionsList = completions.items; - - const summaries = members.items.map((m: any) => { - const mc = completionsList.filter((c: any) => c.memberId === m.id); - const weekPoints = mc.reduce((sum: number, c: any) => { - const ch = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (ch?.type === "points" ? Number(ch.value) : 0); - }, 0); - const weekMoney = mc.reduce((sum: number, c: any) => { - const ch = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (ch?.type === "money" ? Number(ch.value) : 0); - }, 0); - const bonusPoints = rewardPointsList - .filter((r: any) => r.memberId === m.id) - .reduce((sum: number, r: any) => sum + Number(r.value), 0); - const bonusMoney = rewardCashList - .filter((r: any) => r.memberId === m.id) - .reduce((sum: number, r: any) => sum + Number(r.value), 0); - return { - memberId: m.id, - memberName: m.name || m.username || m.id.slice(0, 6), - memberColor: m.color, - pointsEarned: weekPoints + bonusPoints, - moneyEarned: weekMoney + bonusMoney, - choresCompleted: mc.length, - bonusEarned: bonusPoints, - }; - }); - - // Predicted auto-created rewards (dry-run, never writes) - const predictedRewards: any[] = []; - const existingRewards = rewards.items || []; - for (const cfg of configs.items || []) { - if (cfg.type === "manual") continue; - const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : ""; - const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ""; - const periodCompletions = cfg.period - ? completionsList.filter( - (c: any) => - (c.date || "").slice(0, 10) >= pStart && - (c.date || "").slice(0, 10) <= pEnd, - ) - : completionsList; - const cfgRewards = existingRewards.filter( - (r: any) => r.bonusConfigId === cfg.id, - ); - - const tryEval = (target: any, sourceComps: any[]) => { - let current = 0; - if (cfg.type === "threshold") - current = sourceComps.reduce((sum: number, c: any) => { - const ch = assignedList.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (ch?.type === "points" ? Number(ch.value) : 0); - }, 0); - else if (cfg.type === "count") current = sourceComps.length; - return current; - }; - - if (cfg.target === "individual") { - const targets = cfg.memberId - ? members.items.filter((m: any) => m.id === cfg.memberId) - : members.items; - for (const m of targets) { - if (cfgRewards.some((r: any) => r.memberId === m.id)) continue; - const current = tryEval( - m, - periodCompletions.filter((c: any) => c.memberId === m.id), - ); - if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue)) - predictedRewards.push({ - config: cfg.name, - memberName: m.name || m.id.slice(0, 6), - type: cfg.rewardType, - value: Number(cfg.rewardValue) || 0, - detail: `${cfg.type} ${current}/${cfg.criteriaValue}`, - }); - } - } else if (cfg.target === "collaborative") { - if (cfgRewards.length) continue; - const allIds = members.items.map((m: any) => m.id); - const teamComps = periodCompletions.filter((c: any) => - allIds.includes(c.memberId), - ); - const current = tryEval(cfg, teamComps); - if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue)) - predictedRewards.push({ - config: cfg.name, - memberName: "Everyone", - type: cfg.rewardType, - value: Number(cfg.rewardValue) || 0, - detail: `${cfg.type} ${current}/${cfg.criteriaValue}`, - }); - } else if (cfg.target === "competitive") { - if (cfgRewards.length) continue; - const scored: { memberId: string; name: any; current: number }[] = - members.items.map((m: any) => ({ - memberId: m.id, - name: m.name, - current: tryEval( - cfg, - periodCompletions.filter((c: any) => c.memberId === m.id), - ), - })); - const qualified = scored.filter( - (st) => st.current >= Number(cfg.criteriaValue), - ); - const eligible = qualified.length - ? qualified - : scored.filter((st) => st.current > 0); - const winner = eligible.sort((aa, bb) => bb.current - aa.current)[0]; - if (winner) - predictedRewards.push({ - config: cfg.name, - memberName: winner.name, - type: cfg.rewardType, - value: Number(cfg.rewardValue) || 0, - detail: `winner ${winner.current} pts`, - }); - } - } - - // All completions would reset on rollover (next week) - const nextWeekStart = addDays(ws, 7); - - return c.json({ - simulateEow: true, - weekStart: ws, - weekEnd: we, - nextWeekStart, - summaries, - predictedRewards, - completionsThisWeek: completionsList.length, - }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin CRUD: Bonus Templates ──────────────────────── - -app.get("/api/admin/:famId/bonus-templates", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("bonus_templates", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return c.json([]); - } -}); - -app.post("/api/admin/:famId/bonus-templates", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - if (body.occurrence === "once") body.period = ""; - const record = await pb.create("bonus_templates", { famId, ...body }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/bonus-templates/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - const body = await c.req.json(); - if (body.occurrence === "once") body.period = ""; - const record = await pb.update("bonus_templates", id, body); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.delete("/api/admin/:famId/bonus-templates/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - await pb.delete("bonus_templates", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin CRUD: Bonus Configs ─────────────────────────── - -app.get("/api/admin/:famId/bonus-configs", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("bonus_configs", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - // collection may not exist yet — return empty - return c.json([]); - } -}); - -app.post("/api/admin/:famId/bonus-configs", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - if (body.occurrence === "once") body.period = ""; - const record = await pb.create("bonus_configs", { - famId, - ...body, - status: "active", - }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.patch("/api/admin/:famId/bonus-configs/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - const body = await c.req.json(); - if (body.occurrence === "once") body.period = ""; - const record = await pb.update("bonus_configs", id, body); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.delete("/api/admin/:famId/bonus-configs/:id", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - await pb.delete("bonus_configs", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -app.post( - "/api/admin/:famId/bonus-configs/:id/destroy", - requireAdmin, - async (c) => { - try { - const { id } = c.req.param(); - await pb.delete("bonus_configs", id); - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } - }, -); - -app.post( - "/api/admin/:famId/bonus-configs/:id/assign", - requireAdmin, - async (c) => { - try { - const { famId, id } = c.req.param(); - const body = await c.req.json(); - const updates: Record = { status: "active" }; - if (body.target) updates.target = body.target; - if (body.memberId !== undefined) updates.memberId = body.memberId || null; - const record = await pb.update("bonus_configs", id, updates); - return c.json(record); - } catch (err) { - return handleError(c, err); - } - }, -); - -app.post( - "/api/admin/:famId/bonus-configs/:id/complete", - requireAdmin, - async (c) => { - try { - const { famId, id } = c.req.param(); - const record = await pb.update("bonus_configs", id, { - status: "completed", - }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } - }, -); - -// ── Admin: Bonus Config Progress ──────────────────────── - -app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - let configsData: any = { items: [] }; - try { - configsData = await pb.getList( - "bonus_configs", - `famId = '${famId}' && status = 'active'`, - ); - } catch {} - const [members, assigned, completions] = await Promise.all([ - pb.getList("users", `famId = '${famId}' && role = 'child'`), - pb.getList("assigned_chores", `famId = '${famId}'`), - pb.getList("completions", `famId = '${famId}'`), - ]); - - const assignedList = assigned.items; - const completionsList = completions.items; - let allRewards: any = { items: [] }; - try { - allRewards = await pb.getList("rewards", `famId = '${famId}'`); - } catch {} // schema may not have new fields - - const result: any[] = []; - - for (const cfg of configsData.items) { - const cfgRewards = allRewards.items.filter( - (r: any) => r.bonusConfigId === cfg.id, - ); - const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : ""; - const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ""; - const periodCompletions = cfg.period - ? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd) - : completionsList; - - const progress: any[] = []; - - if (cfg.target === "collaborative") { - const teamCompletions = periodCompletions.filter((c: any) => - members.items.some((m: any) => m.id === c.memberId), - ); - let teamCurrent = 0; - if (cfg.type === "threshold") { - teamCurrent = teamCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - } else if (cfg.type === "count") { - teamCurrent = teamCompletions.length; - } - const teamReward = cfgRewards[0]; - progress.push({ - memberId: "__team__", - memberName: "Team Total", - memberColor: "#8b5cf6", - current: teamCurrent, - criteriaValue: cfg.criteriaValue || 0, - reward: teamReward - ? { id: teamReward.id, status: teamReward.status } - : null, - state: teamReward ? teamReward.status : "pending", - achieved: teamReward ? true : false, - }); - } - - if (cfg.target !== "collaborative") { - const progressMembers = - cfg.target === "individual" && cfg.memberId - ? members.items.filter((m: any) => m.id === cfg.memberId) - : members.items; - for (const m of progressMembers) { - const memberCompletions = periodCompletions.filter( - (c: any) => c.memberId === m.id, - ); - const memberReward = cfgRewards.find((r: any) => r.memberId === m.id); - - let current = 0; - if (cfg.type === "threshold") { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - } else if (cfg.type === "count") { - current = memberCompletions.length; - } else if (cfg.type === "manual") { - current = 0; - } - - progress.push({ - memberId: m.id, - memberName: m.name, - memberColor: m.color, - current, - criteriaValue: cfg.criteriaValue || 0, - reward: memberReward - ? { id: memberReward.id, status: memberReward.status } - : null, - state: memberReward ? memberReward.status : "pending", - achieved: memberReward ? true : false, - }); - } - } - - result.push({ - config: cfg, - progress, - periodStart: pStart, - periodEnd: pEnd, - }); - } - - return c.json(result); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Auto-evaluate bonus configs after completions ────── - -async function evaluateFam(famId: string): Promise { - let configs: any[] = []; - try { - configs = ( - await pb.getList( - "bonus_configs", - `famId = '${famId}' && status = 'active' && type != 'manual'`, - ) - ).items; - } catch { - return; - } - if (!configs.length) return; - - const [allMembers, allAssigned, allCompletions] = await Promise.all([ - pb.getList("users", `famId = '${famId}' && role = 'child'`), - pb.getList("assigned_chores", `famId = '${famId}'`), - pb.getList("completions", `famId = '${famId}'`), - ]); - let allRewards: any = { items: [] }; - try { - allRewards = await pb.getList("rewards", `famId = '${famId}'`); - } catch {} - const paydayEval = await getFamPayday(famId); - const tzEval = await getFamTimezone(famId); - - for (const cfg of configs) { - const pStart2 = cfg.period - ? periodStart(cfg.period, paydayEval, tzEval) - : ""; - const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : ""; - const periodCompletions = cfg.period - ? allCompletions.items.filter( - (c: any) => - (c.date || "").slice(0, 10) >= pStart2 && - (c.date || "").slice(0, 10) <= pEnd, - ) - : allCompletions.items; - - const existingRewards = allRewards.items.filter( - (r: any) => r.bonusConfigId === cfg.id, - ); - let createdReward = false; - - if (cfg.target === "individual") { - const targetMembers = cfg.memberId - ? allMembers.items.filter((m: any) => m.id === cfg.memberId) - : allMembers.items; - for (const m of targetMembers) { - const memberCompletions = periodCompletions.filter( - (c: any) => c.memberId === m.id, - ); - let current = 0; - if (cfg.type === "threshold") { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.items.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - } else if (cfg.type === "count") { - current = memberCompletions.length; - } - const achieved = - cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue); - - const memberReward = existingRewards.find( - (r: any) => r.memberId === m.id, - ); - - // Clean up reward if threshold is no longer met (e.g. after revoke) - if (memberReward && !achieved) { - if (memberReward.status !== "claimed") { - try { - await pb.delete("rewards", memberReward.id); - } catch {} - } - continue; - } - - // Skip if already rewarded and still achieved - if (memberReward) continue; - - if (achieved) { - const label = - cfg.rewardType === "cash" - ? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}` - : `${cfg.name} – ${cfg.rewardValue}`; - const now = new Date().toISOString(); - await pb.create("rewards", { - famId, - memberId: m.id, - bonusConfigId: cfg.id, - label, - value: Number(cfg.rewardValue) || 0, - rewardType: cfg.rewardType, - status: cfg.rewardType === "points" ? "claimed" : "unclaimed", - claimedAt: cfg.rewardType === "points" ? now : null, - date: now.slice(0, 10), - ...claimableStamp(cfg, paydayEval, tzEval), - }); - createdReward = true; - } - } - } else if (cfg.target === "collaborative") { - const allMemberIds = allMembers.items.map((m: any) => m.id); - const teamCompletions = periodCompletions.filter((c: any) => - allMemberIds.includes(c.memberId), - ); - let total = 0; - if (cfg.type === "threshold") { - total = teamCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.items.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - } else if (cfg.type === "count") { - total = teamCompletions.length; - } - const achieved = - cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue); - - // Clean up collaborative rewards if threshold is no longer met - if (!achieved && existingRewards.length > 0) { - for (const r of existingRewards) { - if (r.status !== "claimed") { - try { - await pb.delete("rewards", r.id); - } catch {} - } - } - continue; - } - - if (achieved && existingRewards.length === 0) { - for (const m of allMembers.items) { - const label = - cfg.rewardType === "cash" - ? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}` - : `${cfg.name} – ${cfg.rewardValue}`; - const now = new Date().toISOString(); - await pb.create("rewards", { - famId, - memberId: m.id, - bonusConfigId: cfg.id, - label, - value: Number(cfg.rewardValue) || 0, - rewardType: cfg.rewardType, - status: cfg.rewardType === "points" ? "claimed" : "unclaimed", - claimedAt: cfg.rewardType === "points" ? now : null, - date: now.slice(0, 10), - ...claimableStamp(cfg, paydayEval, tzEval), - }); - createdReward = true; - } - } - } else if (cfg.target === "competitive") { - const scored = allMembers.items.map((m: any) => { - const memberCompletions = periodCompletions.filter( - (c: any) => c.memberId === m.id, - ); - let current = 0; - if (cfg.type === "threshold") { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.items.find( - (a: any) => a.id === c.assignedChoreId, - ); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - } else if (cfg.type === "count") { - current = memberCompletions.length; - } - return { memberId: m.id, name: m.name, current }; - }); - const qualified = scored.filter( - (s) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue), - ); - const eligible = - qualified.length > 0 ? qualified : scored.filter((s) => s.current > 0); - const winner = eligible.sort((a, b) => b.current - a.current)[0]; - - // Clean up competitive reward if no winner or winner changed - if (existingRewards.length > 0) { - const existing = existingRewards[0]; - const stillValid = - winner && existing.memberId === winner.memberId && winner.current > 0; - if (!stillValid && existing.status !== "claimed") { - try { - await pb.delete("rewards", existing.id); - } catch {} - } - } - - if (winner && existingRewards.length === 0) { - const label = - cfg.rewardType === "cash" - ? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}` - : `${cfg.name} – ${cfg.rewardValue}`; - const now = new Date().toISOString(); - await pb.create("rewards", { - famId, - memberId: winner.memberId, - bonusConfigId: cfg.id, - label, - value: Number(cfg.rewardValue) || 0, - rewardType: cfg.rewardType, - status: cfg.rewardType === "points" ? "claimed" : "unclaimed", - claimedAt: cfg.rewardType === "points" ? now : null, - date: now.slice(0, 10), - ...claimableStamp(cfg, paydayEval, tzEval), - }); - createdReward = true; - } - } - - // Auto-complete 'once' configs that have rewards - if ( - cfg.occurrence === "once" && - (existingRewards.length > 0 || createdReward) - ) { - try { - await pb.update("bonus_configs", cfg.id, { status: "completed" }); - } catch {} - } - } -} - -// ── Admin: Bonus Config Tally ────────────────────────── - -app.get("/api/admin/:famId/bonus-configs/tallies", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - const configs = await pb.getList("bonus_configs", `famId = '${famId}'`); - const rewards = await pb.getList("rewards", `famId = '${famId}'`); - - const result: Record = {}; - for (const cfg of configs.items) { - const cfgRewards = rewards.items.filter( - (r: any) => r.bonusConfigId === cfg.id, - ); - let allTime = cfgRewards.length; - let thisPeriod = allTime; - if (cfg.period) { - const pStart = periodStart(cfg.period, payday, tz); - const pEnd = periodEnd(cfg.period, pStart); - thisPeriod = cfgRewards.filter( - (r: any) => r.date >= pStart && r.date <= pEnd, - ).length; - } - result[cfg.id] = { thisPeriod, allTime }; - } - return c.json(result); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Manual Trigger ─────────────────────────────── - -app.post( - "/api/admin/:famId/bonus-configs/:id/trigger", - requireAdmin, - async (c) => { - try { - const { famId, id } = c.req.param(); - const body = await c.req.json(); - const configs = await pb.getList( - "bonus_configs", - `famId = '${famId}' && id = '${id}' && status = 'active'`, - ); - const cfg = configs.items?.[0]; - if (!cfg) return c.json({ error: "Bonus config not found" }, 404); - if (cfg.type !== "manual") - return c.json( - { error: "Only manual-type configs can be triggered" }, - 400, - ); - - const existingRewards = await pb.getList( - "rewards", - `famId = '${famId}' && bonusConfigId = '${cfg.id}'`, - ); - - const members = await pb.getList( - "users", - `famId = '${famId}' && role = 'child'`, - ); - - const targetMembers: any[] = []; - if (cfg.target === "competitive" || cfg.target === "collaborative") { - for (const m of members.items) targetMembers.push(m); - } else if (cfg.target === "individual") { - const targetId = cfg.memberId || body.memberId; - if (!targetId) - return c.json( - { error: "memberId required for individual trigger" }, - 400, - ); - const member = members.items.find((m: any) => m.id === targetId); - if (!member) return c.json({ error: "Member not found" }, 404); - targetMembers.push(member); - } - - // Check occurrence limits per target member - for (const m of targetMembers) { - const memberRewards = existingRewards.items.filter( - (r: any) => r.memberId === m.id, - ); - if ( - cfg.occurrence === "once" && - memberRewards.some((r: any) => r.status === "unclaimed") - ) { - return c.json( - { error: `Already issued and pending for ${m.name}` }, - 400, - ); - } - if (cfg.occurrence === "recurring" && cfg.period) { - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - const pStart = periodStart(cfg.period, payday, tz); - const pEnd = periodEnd(cfg.period, pStart); - const periodRewards = memberRewards.filter( - (r: any) => r.date >= pStart && r.date <= pEnd, - ); - if (periodRewards.length > 0) { - return c.json( - { - error: `Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`, - }, - 400, - ); - } - } - } - - const created: any[] = []; - for (const m of targetMembers) { - const label = - cfg.rewardType === "cash" - ? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}` - : `${cfg.name} – ${cfg.rewardValue}`; - const now = new Date().toISOString(); - const record = await pb.create("rewards", { - famId, - memberId: m.id, - bonusConfigId: cfg.id, - label, - value: Number(cfg.rewardValue) || 0, - rewardType: cfg.rewardType, - status: cfg.rewardType === "points" ? "claimed" : "unclaimed", - claimedAt: cfg.rewardType === "points" ? now : null, - date: now.slice(0, 10), - claimable: "immediate", - settleDate: "", - }); - created.push(record); - } - - // Mark 'once' configs as completed after triggering - if (cfg.occurrence === "once") { - await pb.update("bonus_configs", cfg.id, { status: "completed" }); - } - - return c.json({ - triggered: true, - created: created.length, - records: created, - }); - } catch (err) { - return handleError(c, err); - } - }, -); - -// ── Member: Completion Toggle ──────────────────────────── - -app.post("/api/completions/toggle", requireMember, async (c) => { - try { - const famId = c.get("famId"); - const memberId = c.get("memberId"); - const { assignedChoreId, date } = await c.req.json(); - if (!assignedChoreId || !date) { - return c.json({ error: "assignedChoreId and date required" }, 400); - } - // Todos are one-off: any existing completion means it's done, regardless of date. - const chore = await pb - .getList( - "assigned_chores", - `famId = '${famId}' && id = '${assignedChoreId}'`, - ) - .then((r) => r.items?.[0]); - const isTodo = chore?.isTodo; - let filter: string; - if (isTodo) { - filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`; - } else { - // Scope to the chore's period: daily = today, weekly = the current week. - // Otherwise a weekly chore completed yesterday would be toggleable again today. - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - const { from, to } = periodWindow(chore?.frequency, payday, tz); - filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`; - } - const existing = await pb.getList("completions", filter); - if (existing.items?.length > 0) { - await pb.delete("completions", existing.items[0].id); - evaluateFam(famId).catch(() => {}); - return c.json({ completed: false }); - } - const record = await pb.create("completions", { - famId, - memberId, - assignedChoreId, - date, - completedAt: new Date().toISOString(), - }); - evaluateFam(famId).catch(() => {}); - return c.json({ completed: true, record }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Evaluate Bonus Configs (manual trigger) ────── - -app.post( - "/api/admin/:famId/bonus-configs/evaluate", - requireAdmin, - async (c) => { - try { - const { famId } = c.req.param(); - await evaluateFam(famId); - return c.json({ evaluated: true }); - } catch (err) { - return handleError(c, err); - } - }, -); - -// ── Member: Get seasons ───────────────────────────────── - -app.get("/api/members/seasons", requireMember, async (c) => { - try { - const famId = c.get("famId"); - const seasons = await pb.getList("seasons", `famId = '${famId}'`); - return c.json({ seasons: seasons.items, famId }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Member: Get chores (for kanban) ────────────────────── - -app.post("/api/members/my-chores", requireMember, async (c) => { - try { - const famId = c.get("famId"); - const memberId = c.get("memberId"); - const [templates, assigned, completions, rewards, bonusConfigs] = - await Promise.all([ - pb.getList("chore_templates", `famId = '${famId}'`), - pb.getList( - "assigned_chores", - `famId = '${famId}' && memberId = '${memberId}'`, - ), - pb.getList( - "completions", - `famId = '${famId}' && memberId = '${memberId}'`, - ), - pb.getList("rewards", `famId = '${famId}' && memberId = '${memberId}'`), - pb.getList("bonus_configs", `famId = '${famId}' && status = 'active'`), - ]); - const payday = await getFamPayday(famId); - const paydayTime = await getFamPaydayTime(famId); - const timezone = await getFamTimezone(famId); - const settings = await getFamSettings(famId); - const rewardsList = rewards.items; - const configsList = bonusConfigs.items; - return c.json({ - templates: templates.items, - assigned: assigned.items, - completions: completions.items, - rewards: rewardsList, - bonusConfigs: configsList, - payday, - paydayTime, - timezone, - simulateEow: !!settings.simulateEow, - }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: View member kanban data ───────────────────── - -app.get( - "/api/admin/:famId/members/:memberId/chores", - requireAdmin, - async (c) => { - try { - const { famId, memberId } = c.req.param(); - const [templates, assigned, completions, rewards, bonusConfigs] = - await Promise.all([ - pb.getList("chore_templates", `famId = '${famId}'`), - pb.getList( - "assigned_chores", - `famId = '${famId}' && memberId = '${memberId}'`, - ), - pb.getList( - "completions", - `famId = '${famId}' && memberId = '${memberId}'`, - ), - pb.getList( - "rewards", - `famId = '${famId}' && memberId = '${memberId}'`, - ), - pb.getList( - "bonus_configs", - `famId = '${famId}' && status = 'active'`, - ), - ]); - const payday = await getFamPayday(famId); - const rewardsList = rewards.items; - const configsList = bonusConfigs.items; - return c.json({ - templates: templates.items, - assigned: assigned.items, - completions: completions.items, - rewards: rewardsList, - bonusConfigs: configsList, - }); - } catch (err) { - return handleError(c, err); - } - }, -); - -// ── Admin: Rewards ───────────────────────────────────── - -app.get("/api/admin/:famId/completions", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("completions", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -app.get("/api/admin/:famId/rewards", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const data = await pb.getList("rewards", `famId = '${famId}'`); - return c.json(data.items); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/admin/:famId/rewards/:id/claim", requireAdmin, async (c) => { - try { - const { id } = c.req.param(); - const now = new Date().toISOString(); - const record = await pb.update("rewards", id, { - status: "claimed", - claimedAt: now, - }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Issue all requested rewards for a member ───── - -app.post("/api/admin/:famId/rewards/issue-all", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json(); - const { memberId } = body; - if (!memberId) return c.json({ error: "memberId required" }, 400); - const now = new Date().toISOString(); - // Find all claimable rewards for this member (unclaimed or requested) - const rewards = await pb.getList( - "rewards", - `famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`, - ); - let count = 0; - for (const r of rewards.items) { - await pb.update("rewards", r.id, { status: "claimed", claimedAt: now }); - count++; - } - return c.json({ count }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Revoke a completion ──────────────────────── - -app.post( - "/api/admin/:famId/completions/:id/revoke", - requireAdmin, - async (c) => { - try { - const { famId, id } = c.req.param(); - const completion = await pb.getList( - "completions", - `famId = '${famId}' && id = '${id}'`, - ); - if (!completion.items?.length) - return c.json({ error: "Completion not found" }, 404); - await pb.delete("completions", id); - evaluateFam(famId).catch(() => {}); - return c.json({ revoked: true }); - } catch (err) { - return handleError(c, err); - } - }, -); - -app.get("/api/admin/:famId/profile", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const userId = c.req.header("x-session-userid"); - const parents = await pb.getList( - "users", - `famId = '${famId}' && role = 'parent' && id = '${userId}'`, - ); - const parent = parents.items?.[0]; - if (!parent) return c.json({ error: "Admin not found" }, 404); - return c.json({ - id: parent.id, - name: parent.name || "", - color: parent.color || "#6366f1", - email: parent.email || "", - }); - } catch (err) { - return handleError(c, err); - } - }); - - app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const userId = c.req.header("x-session-userid"); - const body = await c.req.json(); - const parents = await pb.getList( - "users", - `famId = '${famId}' && role = 'parent' && id = '${userId}'`, - ); - const parent = parents.items?.[0]; - if (!parent) return c.json({ error: "Admin not found" }, 404); - const update: Record = {}; - if (body.name) update.name = body.name; - if (body.color) update.color = body.color; - if (body.email !== undefined) update.email = body.email; - const record = await pb.update("users", parent.id, update); - return c.json({ - id: record.id, - name: record.name || "", - color: record.color || "#6366f1", - email: record.email || "", - }); - } catch (err) { - return handleError(c, err); - } - }); - -// ── Member: Claim a reward ───────────────────────────── - -app.post("/api/members/rewards/:id/claim", requireMember, async (c) => { - try { - const { id } = c.req.param(); - const famId = c.get("famId"); - const memberId = c.get("memberId"); - const now = new Date().toISOString(); - const tz = await getFamTimezone(famId); - const found = await pb.getList( - "rewards", - `famId = '${famId}' && id = '${id}'`, - ); - const reward = found.items?.[0]; - if (!reward) return c.json({ error: "Reward not found" }, 404); - try { - assertPaydayUnlocked(reward, tz); - } catch (e) { - return c.json( - { error: e instanceof Error ? e.message : "Not claimable yet" }, - 400, - ); - } - const record = await pb.update("rewards", id, { - status: "requested", - requestedAt: now, - }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Member: Request all unclaimed rewards ──────────────── - -app.post("/api/members/rewards/request-all", requireMember, async (c) => { - try { - const famId = c.get("famId"); - const memberId = c.get("memberId"); - const now = new Date().toISOString(); - const tz = await getFamTimezone(famId); - // Find all unclaimed rewards for this member - const rewards = await pb.getList( - "rewards", - `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`, - ); - let count = 0; - for (const r of rewards.items) { - try { - assertPaydayUnlocked(r, tz); - } catch { - continue; // payday-gated reward not yet settled — leave for payday - } - await pb.update("rewards", r.id, { - status: "requested", - requestedAt: now, - }); - count++; - } - return c.json({ count }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Payday: release this period's unpaid cash as an auto-claimed ask ── - -async function releaseWeek(famId: string) { - const payday = await getFamPayday(famId); - const fams = await pb.getList("fams", `id = '${famId}'`); - const fam = fams.items?.[0]; - if (!fam) throw new Error("Fam not found"); - - const tz = resolveTz(fam.timezone || "auto"); - const wsToday = weekStart(payday, tz); // anchor date for the current payday period - - // Payday fires at the configured time on the payday day. Before that moment - // (on the payday day itself) we hold off so the countdown can play out. - const paydayTime = fam.paydayTime || "18:00"; - const target = new Date(wallClockToUtc(wsToday, paydayTime, tz)); - if (Date.now() < target.getTime()) { - return { - settled: false, - notYet: true, - weekStart: wsToday, - target: target.toISOString(), - }; - } - - if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday }; - - const members = await pb.getList( - "users", - `famId = '${famId}' && role = 'child'`, - ); - let cashRewards: any = { items: [] }; - try { - cashRewards = await pb.getList( - "rewards", - `famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`, - ); - } catch {} - - const breakdown: any[] = []; - const now = new Date().toISOString(); - const today = now.slice(0, 10); - - for (const m of members.items) { - const unpaid = (cashRewards.items || []).filter( - (r: any) => r.memberId === m.id && r.status !== "claimed", - ); - const total = unpaid.reduce( - (sum: number, r: any) => sum + Number(r.value), - 0, - ); - if (total > 0) { - // Auto-claim: flip every unpaid cash reward to 'requested' so they land on - // the parent's Issue list, but keep them as individual rows (Issue All totals). - for (const r of unpaid) { - if (r.status !== "requested") { - await pb.update("rewards", r.id, { - status: "requested", - claimedAt: null, - date: (r.date || "").slice(0, 10) || today, - }); - } - } - breakdown.push({ - memberId: m.id, - name: m.name, - total, - rewards: unpaid.map((r: any) => ({ - id: r.id, - label: r.label, - value: Number(r.value), - })), - }); - } - } - - await pb.update("fams", famId, { lastIssued: wsToday }); - return { settled: true, weekStart: wsToday, breakdown }; -} - -async function authorizeFamReq(c: any): Promise { - const sessFam = c.req.header("x-session-famid"); - const sessUser = c.req.header("x-session-userid"); - if (sessFam && sessUser) { - const parents = await pb.getList( - "users", - `famId = '${sessFam}' && role = 'parent' && id = '${sessUser}'`, - ); - if (parents.items?.length) return sessFam; - } - const auth = c.req.header("Authorization") || ""; - const token = auth.startsWith("Bearer ") - ? auth.slice(7) - : c.req.header("x-pb-token"); - if (token) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/users/auth-refresh`, - { method: "POST", headers: { Authorization: `Bearer ${token}` } }, - ); - if (res.ok) { - const body = await res.json().catch(() => ({})); - const record = body?.record; - if (record?.famId) return record.famId; - } - } - return null; -} - -app.post("/api/fam/:famId/payday", async (c) => { - try { - const famId = await authorizeFamReq(c); - if (!famId) return c.json({ error: "Unauthorized" }, 401); - const result = await releaseWeek(famId); - return c.json(result); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Complete week (snapshot to weekly_history) ──── - -app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const payday = await getFamPayday(famId); - const tz = await getFamTimezone(famId); - const ws = weekStart(payday, tz); - - // Evaluate weekly bonus configs first - await evaluateFam(famId); - - // Fetch data for summary - const [members, assigned, completions] = await Promise.all([ - pb.getList("users", `famId = '${famId}' && role = 'child'`), - pb.getList("assigned_chores", `famId = '${famId}'`), - pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), - ]); - - let rewardPointsList: any[] = []; - let rewardCashList: any[] = []; - try { - const [pointsRewards, cashRewards] = await Promise.all([ - pb.getList( - "rewards", - `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`, - ), - pb.getList( - "rewards", - `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`, - ), - ]); - rewardPointsList = pointsRewards.items; - rewardCashList = cashRewards.items; - } catch {} - - const assignedList = assigned.items; - const historyRecords = []; - - for (const m of members.items) { - const memberCompletions = completions.items.filter( - (c: any) => c.memberId === m.id, - ); - - const weekPoints = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === "points" ? Number(chore.value) : 0); - }, 0); - - const weekMoney = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === "money" ? Number(chore.value) : 0); - }, 0); - - const bonusPoints = rewardPointsList - .filter((r: any) => r.memberId === m.id) - .reduce((sum: number, r: any) => sum + Number(r.value), 0); - - const bonusMoney = rewardCashList - .filter((r: any) => r.memberId === m.id) - .reduce((sum: number, r: any) => sum + Number(r.value), 0); - - // Upsert weekly_history - const existing = await pb.getList( - "weekly_history", - `famId = '${famId}' && memberId = '${m.id}' && weekStart = '${ws}'`, - ); - const recordData = { - famId, - memberId: m.id, - weekStart: ws, - pointsEarned: weekPoints + bonusPoints, - moneyEarned: weekMoney + bonusMoney, - choresCompleted: memberCompletions.length, - bonusEarned: bonusPoints, - }; - - if (existing.items?.length > 0) { - await pb.update("weekly_history", existing.items[0].id, recordData); - } else { - const record = await pb.create("weekly_history", recordData); - historyRecords.push(record); - } - } - - return c.json({ - weekStart: ws, - historyRecords, - memberCount: members.items.length, - }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Admin: Generate test data ──────────────────────────── - -app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => { - try { - const { famId } = c.req.param(); - const body = await c.req.json().catch(() => ({})); - const days = body.days || 7; - - const [members, templates] = await Promise.all([ - pb.getList("users", `famId = '${famId}' && role = 'child'`), - pb.getList("chore_templates", `famId = '${famId}'`), - ]); - - if (!members.items.length) - return c.json({ error: "No members found" }, 400); - if (!templates.items.length) - return c.json({ error: "No templates found" }, 400); - - let completionsCreated = 0; - const today = new Date(); - - for (let d = 0; d < days; d++) { - const date = new Date(today); - date.setDate(date.getDate() - d); - const dateStr = date.toISOString().slice(0, 10); - - for (const m of members.items) { - // Randomly complete 50-100% of templates - const completionRate = 0.5 + Math.random() * 0.5; - for (const t of templates.items) { - if (Math.random() > completionRate) continue; - - // Create assigned_chores if needed - let assigned = await pb.getList( - "assigned_chores", - `famId = '${famId}' && memberId = '${m.id}' && templateId = '${t.id}'`, - ); - let assignedId; - if (assigned.items?.length > 0) { - assignedId = assigned.items[0].id; - } else { - const record = await pb.create("assigned_chores", { - famId, - memberId: m.id, - templateId: t.id, - frequency: t.defaultFrequency || "daily", - type: t.defaultType || "points", - value: t.defaultValue || 10, - }); - assignedId = record.id; - } - - // Check if already completed - const existing = await pb.getList( - "completions", - `famId = '${famId}' && memberId = '${m.id}' && assignedChoreId = '${assignedId}' && date = '${dateStr}'`, - ); - if (existing.items?.length > 0) continue; - - await pb.create("completions", { - famId, - memberId: m.id, - assignedChoreId: assignedId, - date: dateStr, - }); - completionsCreated++; - } - } - } - - return c.json({ completionsCreated, days }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Chat ──────────────────────────────────────────────── - -// Resolve the acting user for a chat write. Admins authenticate via the -// httpOnly `session` cookie (parsed server-side) or explicit session headers -// (server-to-server); members via device token. -async function resolveChatActor(c: any) { - // Server-side admin (layout load forwards session headers). - const hsFamId = c.req.header("x-session-famid"); - const hsUserId = c.req.header("x-session-userid"); - if (hsFamId && hsUserId) { - const parents = await pb.getList( - "users", - `famId = '${hsFamId}' && role = 'parent' && id = '${hsUserId}'`, - ); - const parent = parents.items?.[0]; - if (parent) { - return { - famId: hsFamId, - actor: { - id: parent.id, - type: "admin", - name: parent.name || "", - color: parent.color || "#6366f1", - }, - }; - } - } - const auth = c.req.header("Authorization") || ""; - const token = auth.startsWith("Bearer ") - ? auth.slice(7) - : c.req.header("x-pb-token"); - if (token) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/users/auth-refresh`, - { method: "POST", headers: { Authorization: `Bearer ${token}` } }, - ); - if (res.ok) { - const body = await res.json().catch(() => ({})); - const user = body?.record; - if (user?.famId) { - return { - famId: user.famId, - actor: { - id: user.id, - type: user.role === "child" ? "member" : "admin", - name: user.name || user.username || "", - color: user.color || "", - }, - }; - } - } - } - return null; -} - -app.post("/api/chat/:famId/messages", async (c) => { - try { - const auth = await resolveChatActor(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const { content, clientId } = await c.req.json(); - if (!content || !content.trim()) { - return c.json({ error: "content required" }, 400); - } - const record = await pb.create("messages", { - famId: auth.famId, - authorType: auth.actor.type, - authorId: auth.actor.id, - authorName: auth.actor.name, - authorColor: auth.actor.color, - content: content.trim(), - createdAt: new Date().toISOString(), - clientId: clientId ? String(clientId).slice(0, 64) : "", - }); - return c.json(record); - } catch (err) { - return handleError(c, err); - } -}); - -app.post("/api/chat/:famId/typing", async (c) => { - try { - const auth = await resolveChatActor(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const { typing } = await c.req.json(); - const existing = await pb.getList( - "chat_typing", - `famId = '${auth.famId}' && actorId = '${auth.actor.id}' && actorType = '${auth.actor.type}'`, - ); - const row = { - famId: auth.famId, - actorId: auth.actor.id, - actorType: auth.actor.type, - authorName: auth.actor.name, - authorColor: auth.actor.color, - typing: !!typing, - }; - if (existing.items?.length) { - await pb.update("chat_typing", existing.items[0].id, row); - } else { - await pb.create("chat_typing", row); - } - return c.json({ ok: true }); - } catch (err) { - return handleError(c, err); - } -}); - -// Current user's chat identity (name, color, actorId) + famId. -app.get("/api/chat/me", async (c) => { - try { - const auth = await resolveChatActor(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - return c.json({ famId: auth.famId, actor: auth.actor }); - } catch (err) { - return handleError(c, err); - } -}); - -// ── Start server ───────────────────────────────────────── - -const port = parseInt(process.env.PROXY_PORT || PROXY_PORT, 10); - -serve({ fetch: app.fetch, port }, async (info) => { - console.log(`Hono proxy listening on 0.0.0.0:${info.port}`); - try { - await migrate(); - } catch (e) { - console.error("[migrate] Failed:", e); - } -}); diff --git a/proxy/src/pb.ts b/proxy/src/pb.ts deleted file mode 100644 index b745054..0000000 --- a/proxy/src/pb.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; - -let adminToken: string | null = null; -let tokenExpiry = 0; - -async function ensureToken(): Promise { - if (adminToken && Date.now() < tokenExpiry) return adminToken; - 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 auth failed: ${JSON.stringify(data)}`); - adminToken = data.token; - tokenExpiry = Date.now() + 23 * 60 * 60 * 1000; - return adminToken!; -} - -async function request( - method: string, - path: string, - body?: unknown, -): Promise { - const token = await ensureToken(); - const headers: Record = { - Authorization: `Bearer ${token}`, - }; - if (body) headers["Content-Type"] = "application/json"; - return fetch(`${PB_ENDPOINT}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); -} - -export const pb = { - async create(collection: string, data: Record) { - const res = await request("POST", `/api/collections/${collection}/records`, data); - const json = await res.json(); - if (!res.ok) throw new Error(`PB create ${collection}: ${JSON.stringify(json)}`); - return json; - }, - - async update(collection: string, id: string, data: Record) { - const res = await request("PATCH", `/api/collections/${collection}/records/${id}`, data); - const json = await res.json(); - if (!res.ok) throw new Error(`PB update ${collection}: ${JSON.stringify(json)}`); - return json; - }, - - async delete(collection: string, id: string) { - const res = await request("DELETE", `/api/collections/${collection}/records/${id}`); - const body = await res.text(); - if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status} ${body}`); - }, - - async getList(collection: string, filter = "") { - let path = `/api/collections/${collection}/records?perPage=1000`; - if (filter) path += `&filter=${encodeURIComponent(filter)}`; - const res = await request("GET", path); - const json = await res.json(); - if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`); - return json; - }, - - async authWithPassword(identity: string, password: string) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/users/auth-with-password`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity, password }), - }, - ); - const json = await res.json(); - if (!res.ok) throw new Error(`PB auth: ${JSON.stringify(json)}`); - return json; - }, - - async createUser(email: string, password: string) { - return pb.create("users", { - email, - password, - passwordConfirm: password, - emailVisibility: false, - }); - }, -}; diff --git a/proxy/tsconfig.json b/proxy/tsconfig.json deleted file mode 100644 index cbc9aff..0000000 --- a/proxy/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - "noEmit": true, - "paths": { - "@shared/*": ["../shared/*"] - } - }, - "include": ["src/**/*", "../shared/**/*"] -} diff --git a/shared/config.ts b/shared/config.ts index 8070cf8..ee124bd 100644 --- a/shared/config.ts +++ b/shared/config.ts @@ -1,5 +1,4 @@ -// Single source of truth for the three service ports (dev/build-time only, -// used by the Hono proxy). Runtime URLs are set via env (see proxy/src/env.ts). +// Single source of truth for service ports (dev/build-time only). Runtime URLs +// come from env (see frontend/src/env.ts). export const FRONTEND_PORT = "2080"; -export const PROXY_PORT = "3456"; -export const PB_PORT = "8090"; +export const PB_PORT = "8090"; \ No newline at end of file diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index e70e89c..9881297 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -69,20 +69,43 @@ export function rel(name: string, collectionId: string, required = false): Field }; } +// ── Per-user access rules ── +// The app writes directly to PB as the authenticated user (their own token), +// so PB enforces famId scoping instead of running everything as superuser. +// Only genuinely privileged app-level actions (signup, OTP join, member +// creation, superuser dashboard, CRON/webhook) use the superuser client. +// +// Admin-only collections require role='parent'; child-accessible collections +// (completions toggle, reward claim, chat) are scoped by famId alone. +export const RULE_PARENT_WRITE = + "@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'"; +export const RULE_PARENT_SCOPED = + "famId = @request.auth.famId && @request.auth.role = 'parent'"; +export const RULE_FAM_WRITE = "@request.body.famId = @request.auth.famId"; +export const RULE_FAM_SCOPED = "famId = @request.auth.famId"; +// fams has no self-referencing famId field; its record id IS the famId. +export const RULE_OWN_FAM = "id = @request.auth.famId"; + // ── Collection builder ── export function col( name: string, fields: FieldDef[], - rules: { listRule?: string | null; viewRule?: string | null } = {}, + rules: { + listRule?: string | null; + viewRule?: string | null; + createRule?: string | null; + updateRule?: string | null; + deleteRule?: string | null; + } = {}, ): (ids: Record) => CollectionDef { return (ids) => ({ name, type: "base", listRule: rules.listRule ?? "", viewRule: rules.viewRule ?? "", - createRule: null, - updateRule: null, - deleteRule: null, + createRule: rules.createRule ?? null, + updateRule: rules.updateRule ?? null, + deleteRule: rules.deleteRule ?? null, fields, }); } @@ -107,7 +130,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ jsonField("featureFlags"), jsonField("seasons"), ], - { listRule: null, viewRule: null }, + { listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM }, )(ids), }, { @@ -124,12 +147,20 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ text("rewardValue", true), number("criteriaValue"), select("period", ["schedule", "daily", "weekly", "monthly"]), - ])(ids), + ], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "settings", build: (ids) => - col("settings", [rel("famId", ids.fams, true), text("webhookUrl")])(ids), + col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "chore_templates", @@ -141,7 +172,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ select("defaultFrequency", ["daily", "weekly"], true), select("defaultType", ["points", "money"], true), number("defaultValue", true), - ])(ids), + ], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "bonus_configs", @@ -159,7 +194,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ rel("memberId", ids.users), select("period", ["schedule", "daily", "weekly", "monthly"]), select("status", ["active", "completed"], true), - ])(ids), + ], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "weekly_history", @@ -172,7 +211,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ number("moneyEarned"), number("choresCompleted"), number("bonusEarned"), - ])(ids), + ], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "seasons", @@ -184,7 +227,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ bool("active"), date("autoDisable"), date("autoStart"), - ])(ids), + ], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "messages", @@ -199,7 +246,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ // Explicit createdAt: PB 0.39 does NOT auto-add createdAt to // API-created collections (0.25 did). Chat filters/sorts on it. date("createdAt"), - ])(ids), + ], { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + })(ids), }, { name: "chat_typing", @@ -211,7 +262,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ text("authorName", true), text("authorColor"), bool("typing"), - ])(ids), + ], { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + })(ids), }, { name: "rewards", @@ -229,7 +284,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ date("claimedAt"), date("requestedAt"), text("date"), - ])(ids), + ], { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + })(ids), }, { name: "assigned_chores", @@ -243,7 +302,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ number("value", true), text("customName"), jsonField("seasonIds"), - ])(ids), + ], { + createRule: RULE_PARENT_WRITE, + updateRule: RULE_PARENT_SCOPED, + deleteRule: RULE_PARENT_SCOPED, + })(ids), }, { name: "completions", @@ -254,6 +317,10 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ rel("assignedChoreId", ids.assigned_chores, true), date("date"), date("completedAt"), - ])(ids), + ], { + createRule: RULE_FAM_WRITE, + updateRule: RULE_FAM_SCOPED, + deleteRule: RULE_FAM_SCOPED, + })(ids), }, ]; From fb18593ac5767779d7343731370e87a31145970b Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 17 Aug 2026 07:41:58 +0100 Subject: [PATCH 2/3] optimize post migration --- AGENTS.md | 6 ++-- MEMORY.md | 2 +- TODO.md | 13 ++++++++ frontend/src/lib/client/api.ts | 21 ++++++------ frontend/src/lib/server/member-otp.ts | 14 ++++---- frontend/src/lib/server/migrate.ts | 19 ++++++----- frontend/src/lib/server/pocketbase.ts | 2 +- frontend/src/lib/server/routeAuth.ts | 28 ---------------- .../src/routes/[fam]/[username]/+page.svelte | 32 ++++++++----------- .../admin/[famId]/assigned-chores/+server.ts | 14 ++++---- .../[famId]/assigned-chores/[id]/+server.ts | 14 ++++---- frontend/src/routes/api/chat/+server.ts | 20 ++++++------ .../routes/api/completions/toggle/+server.ts | 12 ++++--- .../routes/api/fam/[famId]/payday/+server.ts | 12 ++++--- frontend/src/routes/api/members/+server.ts | 17 ++++++++++ frontend/src/routes/api/members/me/+server.ts | 15 --------- .../api/members/rewards/[id]/claim/+server.ts | 12 ++++--- 17 files changed, 121 insertions(+), 132 deletions(-) create mode 100644 TODO.md delete mode 100644 frontend/src/lib/server/routeAuth.ts create mode 100644 frontend/src/routes/api/members/+server.ts delete mode 100644 frontend/src/routes/api/members/me/+server.ts diff --git a/AGENTS.md b/AGENTS.md index f82f862..786c875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ | Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — | - **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`. -- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `user_configs`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**. +- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. 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) and OTP/signup writes. Not an app role. - The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. - Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`). @@ -33,7 +33,7 @@ ## PB Collections (all scoped by `famId`; child/member = `users` row) - `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only) -- `user_configs` — famId, userId, otp, colour, updatedAt (OTP gate for child join) +- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`) - `fams` — name, slug, stripeCustomerId, featureFlags - `chore_templates` — famId, name, defaultValue, defaultFrequency - `assigned_chores` — famId, userId, templateId, frequency, value @@ -197,7 +197,7 @@ All admin and member pages use the following pattern: - Every collection query includes `famId = @request.auth.famId` filter - Super admin bypasses famId filter (access via PB admin API) -- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `user_configs`. No device tokens. Never log raw tokens/secrets. +- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets. - **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 (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono diff --git a/MEMORY.md b/MEMORY.md index 9bc4e2c..fca4786 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -3,7 +3,7 @@ ## 2026-08-17 — Hono proxy removed: everything runs in SvelteKit services - **Decision**: deleted the `proxy/` Hono service entirely. All business logic (admin CRUD, member kanban, weekly summary/EOW, bonus evaluation/trigger/progress, rewards claim/issue, chat, payday settlement, debug data-gen) now lives in `frontend/src/lib/server/services/`, grouped **by app area** (not by role): `fam.ts`, `chores.ts`, `completions.ts`, `rewards.ts`, `bonuses.ts`, `chat.ts`, `settings.ts`, `crud.ts`, `debug.ts`, plus a generic per-resource `crud.ts`. `createServices(pb, user)` returns a per-feature binder; `servicesFor(event)` is the shorthand for loads/form actions. **No role guard** — PB collection rules on the acting user's token are the security boundary (the `admin`/`member` split no longer exists as separate files). -- **Wiring**: `hono.admin.*` (form actions/loads) and `memberApi.*`/chat are now direct service calls or SvelteKit `/api/*` routes (`completions/toggle`, `members/rewards/[id]/claim`, `members/me`, `fam/[famId]/payday`, `chat`, `admin/[famId]/assigned-chores`). Browser admin calls (chores grid) hit SvelteKit `/api/admin/*`. `routeAuth.actingClient(event)` resolves the acting user's PB client from the Bearer header or the `pb_token` cookie. +- **Wiring**: `hono.admin.*` (form actions/loads) and `memberApi.*`/chat are now direct service calls or SvelteKit `/api/*` routes (`completions/toggle`, `members/rewards/[id]/claim`, `members`, `fam/[famId]/payday`, `chat`, `admin/[famId]/assigned-chores`). Browser admin calls (chores grid) hit SvelteKit `/api/admin/*`. The `/api/*` routes read auth straight from `event.locals` (`locals.user` + `createPbClient(locals.pbToken)`) — a separate `actingClient` helper was dropped as redundant since `hooks.server.ts` already resolves the session. No Authorization header; the httpOnly `pb_token` cookie is the auth for same-origin calls. - **Migration relocated**: `proxy/src/migrate.ts` → `frontend/src/lib/server/migrate.ts` (env now via `$app/env/private` + `PB_ENDPOINT` from `pocketbase.ts`), run once per process by `migrate-boot.ts`, kicked off in `hooks.server.ts` (`void migrateOnBoot()`). Schema source of truth remains `shared/pb/schema.ts`. - **Infra**: `pnpm-workspace.yaml` (only `frontend`), root `package.json` (`dev` = `pnpm --filter frontend dev`), `docker/Dockerfile` (no proxy build/deploy), `docker/entrypoint.sh` (no proxy start; app runs schema migration on boot), `docker/nginx.conf` (`/api/` block removed → falls through to `location /` → SvelteKit `:3000`; `/pb/api/` unchanged). Removed `PROXY_URL` env + `PROXY_PORT` from `shared/config.ts` and `frontend/src/env.ts`. Dead `memberApi.myChores`/`requestAll` removed. - **Typecheck**: frontend `svelte-check` = 12 pre-existing canary errors (`.svelte` implicit-any, qrcode decl, RewardType/Frequency casts, signup `string|undefined`); **zero errors in the migration's files**. `pnpm build` (adapter-node) succeeds. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b4e77cb --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +Items: + +- The hono workhorse - see notes later + +## the hono workhorse + +**Actively used (the workhorse):** + +- **Admin reads/writes** via `hono.admin.*` — used heavily by the child kanban (`+page.server.ts` load: members, chore-templates, assigned-chores, weekly-summary, fam, rewards, bonus-configs, completions, settings), the **bonuses** page (17 calls), **ledger** (6), **preferences** (2), **fam dashboard** (4), plus settings `complete-week` and `debug/generate-data`. +- **Member actions** via `memberApi.*` + direct `/api` fetches — `toggleCompletion`, `claimReward`, `payday`, `/api/members/me`, `/api/members/my-chores` (both SSR and browser). +- **Direct client calls** — the chores page hits `/api/admin/:famId/assigned-chores` directly; the kanban hits `/api/members/me`. + +We will tackle this as the next feature `feature/migrate-hono-to-kit` diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index 0f90c67..8486269 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -1,16 +1,13 @@ -// Client-only. All /api calls go same-origin (SvelteKit in dev and prod). +// Client-only. All /api calls go same-origin (SvelteKit in dev and prod); +// auth rides on the httpOnly `pb_token` cookie, so no token/header needed. const BASE_URL = ''; async function memberFetch( method: string, path: string, - token: string, - _famId?: string, body?: unknown, ): Promise { - const headers: Record = { - Authorization: `Bearer ${token}`, - }; + const headers: Record = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(`${BASE_URL}${path}`, { method, @@ -23,13 +20,13 @@ async function memberFetch( } export const memberApi = { - async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) { - return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date }); + async toggleCompletion(famId: string, assignedChoreId: string, date: string) { + return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date }); }, - async claimReward(token: string, famId: string, rewardId: string) { - return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId); + async claimReward(famId: string, rewardId: string) { + return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`); }, - async payday(token: string, famId: string) { - return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId); + async payday(famId: string) { + return memberFetch('POST', `/api/fam/${famId}/payday`); }, }; \ No newline at end of file diff --git a/frontend/src/lib/server/member-otp.ts b/frontend/src/lib/server/member-otp.ts index a3cc112..6e6fc83 100644 --- a/frontend/src/lib/server/member-otp.ts +++ b/frontend/src/lib/server/member-otp.ts @@ -59,7 +59,7 @@ export async function createChild(opts: { } // Admin grants access to a child: creates the users auth record (or re-issues -// OTP if they already exist) + upserts their user_configs. Returns the OTP and +// OTP if they already exist) + upserts their otp. Returns the OTP and // shareable join link (using the whitespace-free handle) for QR display. export async function issueAccess(opts: { famId: string; @@ -67,23 +67,23 @@ export async function issueAccess(opts: { name: string; colour?: string; }) { - const { famId, famSlug, name, colour } = opts; + const { famId, famSlug, name } = opts; const username = handle(name); const otp = generateOtp(); const updatedAt = new Date().toISOString(); - const user = await createChild({ famId, famSlug, name, colour }); + const user = await createChild({ famId, famSlug, name, colour: opts.colour }); const pb = await createSuperClient(); let config = await pb - .collection('user_configs') + .collection('otp') .getFirstListItem(`famId='${famId}' && userId='${user.id}'`) .catch(() => null); if (config) { - await pb.collection('user_configs').update(config.id, { otp, colour, updatedAt }); + await pb.collection('otp').update(config.id, { otp, updatedAt }); } else { - await pb.collection('user_configs').create({ famId, userId: user.id, otp, colour, updatedAt }); + await pb.collection('otp').create({ famId, userId: user.id, otp, updatedAt }); } return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` }; @@ -109,7 +109,7 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp: if (!user || user.role !== 'child') throw new Error('Invalid join link'); let config = await pb - .collection('user_configs') + .collection('otp') .getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`) .catch(() => null); if (!config || config.otp !== otp) throw new Error('Invalid code'); diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 145ea21..1020f7e 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -1752,21 +1752,21 @@ export async function migrate(): Promise { } } - // ── 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 + // ── 30. otp: OTP store (superuser-only) ── + // Holds the rotating one-time code and the OTP-issue timestamp used for the + // 20-minute join 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. + // accurate. userId links to the users auth record so each child's config is + // uniquely addressable. (Display colour lives on users.color, not here.) { - if (!(await getCollection("user_configs"))) { + if (!(await getCollection("otp"))) { 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..."); + console.log("[migrate] Creating otp collection..."); await createCollection({ - name: "user_configs", + name: "otp", type: "base", listRule: null, viewRule: null, @@ -1791,12 +1791,11 @@ export async function migrate(): Promise { 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"); + console.log(" ↳ otp already exists"); } } diff --git a/frontend/src/lib/server/pocketbase.ts b/frontend/src/lib/server/pocketbase.ts index 09d4543..fe7e44f 100644 --- a/frontend/src/lib/server/pocketbase.ts +++ b/frontend/src/lib/server/pocketbase.ts @@ -26,7 +26,7 @@ export function pbUser(event: RequestEvent) { // Superuser PB client (memoized). Reserved for server-only privileged // operations that must bypass collection rules: creating child users, minting -// OTP-login tokens, and verifying OTPs against the superuser-only user_configs. +// OTP-login tokens, and verifying OTPs against the superuser-only otp. let superClient: PocketBase | null = null; export async function createSuperClient() { if (superClient) return superClient; diff --git a/frontend/src/lib/server/routeAuth.ts b/frontend/src/lib/server/routeAuth.ts deleted file mode 100644 index 0b6cee2..0000000 --- a/frontend/src/lib/server/routeAuth.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { createPbClient } from '$lib/server/pocketbase'; -import type { RequestEvent } from '@sveltejs/kit'; - -// Resolve the acting user's PB client from a request: prefer the Authorization -// Bearer token (sent by the browser member API), else the httpOnly session -// cookie. Identity comes from the verified session. Used by the in-app /api/* -// routes that replaced the Hono member endpoints. -export function actingClient(event: RequestEvent) { - const token = - event.request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') || - event.locals.pbToken || - ''; - const u = event.locals.user; - if (!u || !token) throw error(401, 'Unauthorized'); - return { - pb: createPbClient(token), - famId: u.famId, - userId: u.id, - role: u.role, - name: u.name || '', - color: u.color || '#6366f1' - }; -} - -export function err(e: unknown) { - return error(500, e instanceof Error ? e.message : 'Internal error'); -} \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 9fc9eb9..20ed820 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -212,7 +212,7 @@ if (secondsLeft > 0 || eowFired) return; if (!pbToken || !famId) return; eowFired = true; - memberApi.payday(pbToken, famId).catch(() => {}); + memberApi.payday(famId).catch(() => {}); }); function paydayWeekStart(): string { @@ -546,7 +546,7 @@ } try { - await memberApi.toggleCompletion(pbToken, famId, chore.id, todayChild); + await memberApi.toggleCompletion(famId, chore.id, todayChild); const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id); if (optimistic) { famStore.applyRecord('completions', optimistic, 'delete'); @@ -960,14 +960,11 @@ const old = memberName; memberName = nameInput; try { - const res = await fetch('/api/members/me', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${pbToken}` - }, - body: JSON.stringify({ name: nameInput }) - }); +const res = await fetch('/api/members', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: nameInput }) + }); if (!res.ok) memberName = old; } catch { memberName = old; @@ -1022,14 +1019,11 @@ memberColor = color; showColorPicker = false; try { - const res = await fetch('/api/members/me', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${pbToken}` - }, - body: JSON.stringify({ color }) - }); +const res = await fetch('/api/members', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ color }) + }); if (!res.ok) memberColor = old; } catch { memberColor = old; @@ -1253,7 +1247,7 @@ class="wr-cta" onclick={async () => { try { - await memberApi.claimReward(pbToken, famId, r.id); + await memberApi.claimReward(famId, r.id); } catch (e) { claimError = e instanceof Error ? e.message : 'Claim failed'; } diff --git a/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts b/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts index 05d6af3..8c616b2 100644 --- a/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts +++ b/frontend/src/routes/api/admin/[famId]/assigned-chores/+server.ts @@ -1,17 +1,19 @@ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices } from '$lib/server/services'; export async function POST(event: RequestEvent) { - const { pb, famId, userId, role } = actingClient(event); - if (famId !== event.params.famId) { + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); + if (u.famId !== event.params.famId) { return json({ error: 'famId mismatch' }, { status: 403 }); } const body = await event.request.json().catch(() => ({})); try { - const s = createServices(pb, { id: userId, role }); - const record = await s.crud.create('assigned-chores', famId, body); + const s = createServices(pb, { id: u.id, role: u.role }); + const record = await s.crud.create('assigned-chores', u.famId, body); return json(record); } catch (e) { return json({ error: e instanceof Error ? e.message : 'create failed' }, { status: 400 }); diff --git a/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts b/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts index 31943a3..8da7ce4 100644 --- a/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts +++ b/frontend/src/routes/api/admin/[famId]/assigned-chores/[id]/+server.ts @@ -1,17 +1,19 @@ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices } from '$lib/server/services'; export async function DELETE(event: RequestEvent) { - const { pb, famId, userId, role } = actingClient(event); - if (famId !== event.params.famId) { + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); + if (u.famId !== event.params.famId) { return json({ error: 'famId mismatch' }, { status: 403 }); } const id = event.params.id!; try { - const s = createServices(pb, { id: userId, role }); - await s.crud.remove('assigned-chores', famId, id); + const s = createServices(pb, { id: u.id, role: u.role }); + await s.crud.remove('assigned-chores', u.famId, id); return json({ ok: true }); } catch (e) { return json({ error: e instanceof Error ? e.message : 'delete failed' }, { status: 400 }); diff --git a/frontend/src/routes/api/chat/+server.ts b/frontend/src/routes/api/chat/+server.ts index ae5134a..bb0af43 100644 --- a/frontend/src/routes/api/chat/+server.ts +++ b/frontend/src/routes/api/chat/+server.ts @@ -1,5 +1,5 @@ -import { json } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; +import { json, error } from '@sveltejs/kit'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices, type ChatActor } from '$lib/server/services'; import type { RequestEvent } from '@sveltejs/kit'; @@ -15,18 +15,20 @@ export async function POST(event: RequestEvent) { const body = (await event.request.json().catch(() => null)) as Body | null; if (!body || !body.action) return json({ error: 'missing action' }, { status: 400 }); - const { pb, famId: sessionFamId, userId, role, name, color } = actingClient(event); + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); const actor: ChatActor = { - id: userId, - type: role === 'parent' ? 'admin' : 'member', - name, - color + id: u.id, + type: u.role === 'parent' ? 'admin' : 'member', + name: u.name, + color: u.color || '#6366f1' }; - const famId = body.famId || sessionFamId || ''; + const famId = body.famId || u.famId || ''; if (!famId) return json({ error: 'famId required' }, { status: 400 }); try { - const s = createServices(pb, { id: userId, role }); + const s = createServices(pb, { id: u.id, role: u.role }); const data = body.action === 'typing' ? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) }) diff --git a/frontend/src/routes/api/completions/toggle/+server.ts b/frontend/src/routes/api/completions/toggle/+server.ts index 4bc2165..0b1fe7e 100644 --- a/frontend/src/routes/api/completions/toggle/+server.ts +++ b/frontend/src/routes/api/completions/toggle/+server.ts @@ -1,14 +1,16 @@ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices } from '$lib/server/services'; export async function POST(event: RequestEvent) { - const { pb, famId, userId, role } = actingClient(event); + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); const body = await event.request.json().catch(() => ({})); try { - const s = createServices(pb, { id: userId, role }); - return json(await s.completions.toggle(famId, body)); + const s = createServices(pb, { id: u.id, role: u.role }); + return json(await s.completions.toggle(u.famId, body)); } catch (e) { return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 }); } diff --git a/frontend/src/routes/api/fam/[famId]/payday/+server.ts b/frontend/src/routes/api/fam/[famId]/payday/+server.ts index 7e4f530..763b88d 100644 --- a/frontend/src/routes/api/fam/[famId]/payday/+server.ts +++ b/frontend/src/routes/api/fam/[famId]/payday/+server.ts @@ -1,13 +1,15 @@ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices } from '$lib/server/services'; export async function POST(event: RequestEvent) { - const { pb, famId, userId, role } = actingClient(event); + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); try { - const s = createServices(pb, { id: userId, role }); - return json(await s.fam.payday(famId)); + const s = createServices(pb, { id: u.id, role: u.role }); + return json(await s.fam.payday(u.famId)); } catch (e) { return json({ error: e instanceof Error ? e.message : 'payday failed' }, { status: 400 }); } diff --git a/frontend/src/routes/api/members/+server.ts b/frontend/src/routes/api/members/+server.ts new file mode 100644 index 0000000..19e020b --- /dev/null +++ b/frontend/src/routes/api/members/+server.ts @@ -0,0 +1,17 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { createPbClient } from '$lib/server/pocketbase'; +import { createServices } from '$lib/server/services'; + +export async function PATCH(event: RequestEvent) { + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); + const body = await event.request.json().catch(() => ({})); + try { + const s = createServices(pb, { id: u.id, role: u.role }); + return json(await s.fam.updateProfile(u.famId, body)); + } catch (e) { + return json({ error: e instanceof Error ? e.message : 'update failed' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/frontend/src/routes/api/members/me/+server.ts b/frontend/src/routes/api/members/me/+server.ts deleted file mode 100644 index b0ae416..0000000 --- a/frontend/src/routes/api/members/me/+server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; -import { createServices } from '$lib/server/services'; - -export async function PATCH(event: RequestEvent) { - const { pb, famId, userId, role } = actingClient(event); - const body = await event.request.json().catch(() => ({})); - try { - const s = createServices(pb, { id: userId, role }); - return json(await s.fam.updateProfile(famId, body)); - } catch (e) { - return json({ error: e instanceof Error ? e.message : 'update failed' }, { status: 400 }); - } -} \ No newline at end of file diff --git a/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts b/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts index de72fb0..d22d6d9 100644 --- a/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts +++ b/frontend/src/routes/api/members/rewards/[id]/claim/+server.ts @@ -1,14 +1,16 @@ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { actingClient } from '$lib/server/routeAuth'; +import { createPbClient } from '$lib/server/pocketbase'; import { createServices } from '$lib/server/services'; export async function POST(event: RequestEvent) { - const { pb, famId, userId, role } = actingClient(event); + const u = event.locals.user; + if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); + const pb = createPbClient(event.locals.pbToken); const id = event.params.id!; try { - const s = createServices(pb, { id: userId, role }); - return json(await s.rewards.claim(famId, id)); + const s = createServices(pb, { id: u.id, role: u.role }); + return json(await s.rewards.claim(u.famId, id)); } catch (e) { return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 }); } From 31fd9cce386617805f987d418c3ce3e5b924ea74 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 17 Aug 2026 08:55:27 +0100 Subject: [PATCH 3/3] consolodate migrate schema --- AGENTS.md | 2 + MEMORY.md | 8 + frontend/src/lib/server/migrate.ts | 2138 ++-------------------------- shared/pb/schema.ts | 20 +- 4 files changed, 139 insertions(+), 2029 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 786c875..43298f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl +> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. + ## Routes ``` diff --git a/MEMORY.md b/MEMORY.md index fca4786..1f96f32 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,13 @@ # FamChore v2 — Development Memory +## 2026-08-17 — Schema collapse to single source of truth (Option 1) + +- **Decision**: abandoned incremental migration history. `SCHEMA_PLAN` (`shared/pb/schema.ts`) is now the true, current schema; `migrate.ts` collapsed from 2096 → 183 lines to a **fresh-only bootstrap** (`ensureSchema()` skips if `fams` exists — no incremental steps). The app isn't live and data is disposable, so there's nothing to preserve; a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. +- **Folded the net effect of ~40 hand-written steps into `SCHEMA_PLAN`** (verified against the live PB): `fams.{payday,lastIssued,paydayTime,timezone}` (dropped stale `seasons`), `settings.simulateEow`, `messages.clientId`, `assigned_chores.{isTodo,startDate,completeBy}`. `bonus_configs.memberId`, `weekly_history/rewards/assigned_chores/completions.memberId` → `users`. +- **Out of `SCHEMA_PLAN`** (handled in `migrate.ts`): the native `users` auth collection (custom `famId`/`role`/`username`/`color` fields + `username` unique index + password-auth identity + famId-scoped rules, via `ensureUsers`) and the superuser-only `otp` collection (null rules — `col()` can't express `null`, via `ensureOtp`). +- **Gotcha**: `col()` coerces `rules.listRule ?? ""` → can't emit `null` rules, so `otp` stays out of the plan. `ensureSchema` needs the live PB to confirm the true schema (SCHEMA_PLAN was stale before this). +- Verify path: wipe dev PB + restart frontend (needs user OK) so `migrateOnBoot` rebuilds from `SCHEMA_PLAN`. + ## 2026-08-17 — Hono proxy removed: everything runs in SvelteKit services - **Decision**: deleted the `proxy/` Hono service entirely. All business logic (admin CRUD, member kanban, weekly summary/EOW, bonus evaluation/trigger/progress, rewards claim/issue, chat, payday settlement, debug data-gen) now lives in `frontend/src/lib/server/services/`, grouped **by app area** (not by role): `fam.ts`, `chores.ts`, `completions.ts`, `rewards.ts`, `bonuses.ts`, `chat.ts`, `settings.ts`, `crud.ts`, `debug.ts`, plus a generic per-resource `crud.ts`. `createServices(pb, user)` returns a per-feature binder; `servicesFor(event)` is the shorthand for loads/form actions. **No role guard** — PB collection rules on the acting user's token are the security boundary (the `admin`/`member` split no longer exists as separate files). diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 1020f7e..6b25dd9 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -24,9 +24,7 @@ async function getCollection(name: string): Promise { const t = await auth(); const res = await fetch( `${PB_ENDPOINT}/api/collections?filter=name='${name}'`, - { - headers: { Authorization: `Bearer ${t}` }, - }, + { headers: { Authorization: `Bearer ${t}` } }, ); const data = await res.json(); return data?.items?.[0] || null; @@ -36,15 +34,11 @@ async function createCollection(col: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, body: JSON.stringify(col), }); const data = await res.json(); - if (!res.ok) - throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); + if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); console.log(` ✓ Created collection: ${col.name}`); return data?.id || null; } @@ -53,31 +47,124 @@ async function updateCollection(id: string, col: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, body: JSON.stringify(col), }); const data = await res.json(); - if (!res.ok) - throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); + if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); console.log(` ✓ Updated collection: ${col.name || id}`); } -// Bootstrap the full base schema on a fresh PocketBase (docker first boot). -// Idempotent — skips collections that already exist. Schema is shared with -// seed.ts via shared/pb/schema.ts. +// Apply the custom fields + rules the app relies on to PB's native `users` +// auth collection (created automatically on first serve). Children live here as +// role='child'; username is a password-auth identity so the server can +// authWithPassword(derivedPassword) at OTP join time. +async function ensureUsers(ids: Record): Promise { + const usersCol = await getCollection("users"); + if (!usersCol) throw new Error("users collection not found"); + const famsId = ids.fams || (await getCollection("fams"))?.id; + if (!famsId) throw new Error("fams collection not found"); + + const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); + let changed = false; + + const emailField = usersCol.fields.find((f: any) => f.name === "email"); + if (emailField && emailField.required) { + emailField.required = false; + changed = true; + } + if (!has("famId")) { + usersCol.fields.push({ + name: "famId", type: "relation", required: false, + collectionId: famsId, maxSelect: 1, cascadeDelete: false, + }); + changed = true; + } + if (!has("role")) { + usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 }); + changed = true; + } + if (!has("username")) { + usersCol.fields.push({ name: "username", type: "text", required: true }); + changed = true; + } + if (!has("color")) { + usersCol.fields.push({ name: "color", type: "text", required: false }); + changed = true; + } + + let indexes = usersCol.indexes || []; + if (!indexes.some((i: string) => /username/i.test(i))) { + indexes = [...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"]; + changed = true; + } + + const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] }; + const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"]; + if (!identityFields.includes("username")) { + identityFields.push("username"); + changed = true; + } + + 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) { + changed = true; + } + + if (changed) { + await updateCollection(usersCol.id, { + name: "users", + type: "auth", + listRule, + viewRule: listRule, + createRule: usersCol.createRule || "", + updateRule: parentWrite, + deleteRule: parentWrite, + fields: usersCol.fields, + indexes, + passwordAuth: { enabled: true, identityFields }, + }); + } +} + +// Superuser-only OTP store for the child join gate. Holds the rotating code and +// its issue timestamp (20-min window). Not public — read/written via the +// superuser client only. +async function ensureOtp(ids: Record): Promise { + if (await getCollection("otp")) return; + const famsId = ids.fams || (await getCollection("fams"))?.id; + const usersId = ids.users || (await getCollection("users"))?.id; + if (!famsId || !usersId) throw new Error("fams/users collection not found"); + await createCollection({ + name: "otp", + type: "base", + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, + { name: "userId", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "otp", type: "text", required: false }, + { name: "updatedAt", type: "text", required: false }, + ], + }); +} + +// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if +// `fams` already exists (data is disposable; there is no incremental migration +// history). async function ensureSchema(): Promise { if (await getCollection("fams")) { - console.log("[migrate] Base schema already present — skipping bootstrap."); + console.log("[migrate] Schema already present — skipping bootstrap."); return; } - console.log("[migrate] Bootstrapping base schema on fresh PocketBase..."); + console.log("[migrate] Bootstrapping schema on fresh PocketBase..."); const ids: Record = {}; - // `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) { @@ -85,2012 +172,13 @@ async function ensureSchema(): Promise { if (createdId) ids[entry.name] = createdId; } - // fams is sensitive → superadmin-only (proxy/server reads). Not public. - const famsId = ids.fams; - if (famsId) await updateCollection(famsId, { viewRule: null, listRule: null }); - console.log("[migrate] Base schema bootstrapped."); + await ensureUsers(ids); + await ensureOtp(ids); + console.log("[migrate] Schema bootstrapped."); } export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); - await ensureSchema(); - - // ── 0. Lock fams to superadmin-only (sensitive; read via proxy/server) ── - { - const famsCol = await getCollection("fams"); - if (famsCol) { - const rules = { viewRule: null, listRule: null }; - if (famsCol.viewRule !== null || famsCol.listRule !== null) { - console.log("[migrate] Locking fams collection to superadmin-only..."); - await updateCollection(famsCol.id, rules); - } else { - console.log(" ↳ fams already superadmin-only"); - } - } - } - - // ── 1. Create bonus_configs if missing ── - const existing = await getCollection("bonus_configs"); - if (!existing) { - // Need to get fams collection ID first - const famsCol = await getCollection("fams"); - if (!famsCol) throw new Error("fams collection not found"); - const famsId = famsCol.id; - - console.log("[migrate] Creating bonus_configs collection..."); - await createCollection({ - name: "bonus_configs", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsId, - maxSelect: 1, - cascadeDelete: false, - }, - { name: "name", type: "text", required: true }, - { name: "description", type: "text", required: false }, - { - name: "target", - type: "select", - required: true, - values: ["individual", "competitive", "collaborative"], - maxSelect: 1, - }, - { - name: "type", - type: "select", - required: true, - values: ["threshold", "count", "manual"], - maxSelect: 1, - }, - { - name: "occurrence", - type: "select", - required: true, - values: ["recurring", "once"], - maxSelect: 1, - }, - { - name: "rewardType", - type: "select", - required: true, - values: ["points", "cash", "prize"], - maxSelect: 1, - }, - { name: "rewardValue", type: "text", required: true }, - { name: "criteriaValue", type: "number", required: false }, - { - name: "period", - type: "select", - required: false, - values: ["schedule", "daily", "weekly", "monthly"], - maxSelect: 1, - }, - { - name: "status", - type: "select", - required: true, - values: ["active", "archived"], - maxSelect: 1, - }, - ], - }); - } else { - console.log(` ↳ bonus_configs already exists`); - - const targetField = existing.fields.find((f: any) => f.name === "target"); - const periodField = existing.fields.find((f: any) => f.name === "period"); - const needTargetUpdate = - targetField && !targetField.values.includes("collaborative"); - const needPeriodUpdate = - periodField && !periodField.values.includes("daily"); - - if (needTargetUpdate || needPeriodUpdate) { - console.log("[migrate] Updating bonus_configs fields..."); - if (needTargetUpdate) - targetField.values = ["individual", "competitive", "collaborative"]; - if (needPeriodUpdate) - periodField.values = ["schedule", "daily", "weekly", "monthly"]; - await updateCollection(existing.id, { - name: "bonus_configs", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: existing.fields, - }); - } - } - - // ── 2. Update rewards collection ── - const rewardsCol = await getCollection("rewards"); - if (rewardsCol) { - const fieldNames = rewardsCol.fields.map((f: any) => f.name); - const needsUpdate = - !fieldNames.includes("date") || - fieldNames.includes("autoClaimed") || - fieldNames.includes("weekStart") || - fieldNames.includes("month"); - - if (needsUpdate) { - console.log("[migrate] Updating rewards collection schema..."); - - const bonusConfigsCol = await getCollection("bonus_configs"); - - const keepFields = rewardsCol.fields.filter((f: any) => - [ - "famId", - "memberId", - "label", - "value", - "claimed", - "claimedAt", - "rewardType", - "bonusConfigId", - "created", - "updated", - "id", - ].includes(f.name), - ); - - const newFields = [ - ...keepFields, - ...(bonusConfigsCol && !fieldNames.includes("bonusConfigId") - ? [ - { - name: "bonusConfigId", - type: "relation", - required: false, - collectionId: bonusConfigsCol.id, - maxSelect: 1, - cascadeDelete: false, - }, - ] - : []), - ...(fieldNames.includes("rewardType") - ? [] - : [ - { - name: "rewardType", - type: "select", - required: true, - values: ["cash", "prize", "points"], - maxSelect: 1, - }, - ]), - ...(fieldNames.includes("claimedAt") - ? [] - : [{ name: "claimedAt", type: "date", required: false }]), - { name: "date", type: "text", required: false }, - ]; - - await updateCollection(rewardsCol.id, { - name: "rewards", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: newFields, - }); - - // Backfill existing rewards - const today = new Date().toISOString().slice(0, 10); - const backfillRes = await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records?perPage=200`, - { headers: { Authorization: `Bearer ${await auth()}` } }, - ); - const backfillData = await backfillRes.json(); - if (backfillData?.items) { - for (const r of backfillData.items) { - const t2 = await auth(); - const patches: Record = {}; - if (!r.date) { - patches.date = r.claimedAt?.slice(0, 10) || today; - } - if (r.rewardType === "points" && !r.claimed) { - patches.claimed = true; - patches.claimedAt = new Date().toISOString(); - } - if (Object.keys(patches).length > 0) { - await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t2}`, - }, - body: JSON.stringify(patches), - }, - ); - } - } - console.log(` ✓ Backfilled ${backfillData.items.length} rewards`); - } - } else { - console.log(` ↳ rewards schema is current`); - } - } else { - console.log(` ↳ rewards collection not found (will be created by seed)`); - } - - // Ensure rewards.claimable + rewards.settleDate exist (payday-gated bonuses) - const rewardsCol2 = await getCollection("rewards"); - if (rewardsCol2) { - const fieldNames = rewardsCol2.fields.map((f: any) => f.name); - const missing: any[] = []; - if (!fieldNames.includes("claimable")) { - missing.push({ - name: "claimable", - type: "select", - required: true, - values: ["immediate", "payday"], - maxSelect: 1, - }); - } - if (!fieldNames.includes("settleDate")) { - missing.push({ name: "settleDate", type: "text", required: false }); - } - if (missing.length) { - console.log( - "[migrate] Adding rewards.claimable/settleDate (payday gating)...", - ); - rewardsCol2.fields.push(...missing); - await updateCollection(rewardsCol2.id, { - name: "rewards", - type: "base", - listRule: rewardsCol2.listRule, - viewRule: rewardsCol2.viewRule, - createRule: rewardsCol2.createRule, - updateRule: rewardsCol2.updateRule, - deleteRule: rewardsCol2.deleteRule, - fields: rewardsCol2.fields, - }); - console.log(" ✓ rewards.claimable/settleDate added"); - } else { - console.log(` ↳ rewards.claimable/settleDate already exist`); - } - } - - // ── 3. Update settings collection (drop old fields) ── - const settingsCol = await getCollection("settings"); - if (settingsCol) { - const fieldNames = settingsCol.fields.map((f: any) => f.name); - if ( - fieldNames.includes("pointsThreshold") || - fieldNames.includes("weeklyBonus") - ) { - console.log( - "[migrate] Updating settings collection (dropping old fields)...", - ); - const keepFields = settingsCol.fields.filter((f: any) => - ["famId", "webhookUrl", "created", "updated", "id"].includes(f.name), - ); - await updateCollection(settingsCol.id, { - name: "settings", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: keepFields, - }); - } else { - console.log(` ↳ settings schema is current`); - } - - // Ensure simulateEow debug flag exists (read-only EOW preview toggle) - const simField = settingsCol.fields.some( - (f: any) => f.name === "simulateEow", - ); - if (!simField) { - console.log("[migrate] Adding settings.simulateEow (debug toggle)..."); - settingsCol.fields.push({ name: "simulateEow", type: "bool" }); - await updateCollection(settingsCol.id, { - name: "settings", - type: "base", - listRule: settingsCol.listRule, - viewRule: settingsCol.viewRule, - createRule: settingsCol.createRule, - updateRule: settingsCol.updateRule, - deleteRule: settingsCol.deleteRule, - fields: settingsCol.fields, - }); - } else { - console.log(` ↳ settings.simulateEow already exists`); - } - } - - // ── 5. Add payday field to fams if missing ── - const famsCol = await getCollection("fams"); - if (famsCol) { - const hasPayday = famsCol.fields.some((f: any) => f.name === "payday"); - if (!hasPayday) { - console.log("[migrate] Adding payday field to fams..."); - const paydayField = { - name: "payday", - type: "number", - required: false, - min: 0, - max: 6, - }; - famsCol.fields.push(paydayField); - 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, - }); - } else { - console.log(` ↳ fams.payday already exists`); - } - } - - // ── 5b. Add lastIssued (payday heartbeat) field to fams if missing ── - { - const fc = famsCol || (await getCollection("fams")); - if (fc) { - const hasLastIssued = fc.fields.some((f: any) => f.name === "lastIssued"); - if (!hasLastIssued) { - console.log("[migrate] Adding lastIssued field to fams..."); - fc.fields.push({ name: "lastIssued", type: "text" }); - await updateCollection(fc.id, { - name: "fams", - type: "base", - listRule: fc.listRule, - viewRule: fc.viewRule, - createRule: fc.createRule, - updateRule: fc.updateRule, - deleteRule: fc.deleteRule, - fields: fc.fields, - }); - } else { - console.log(` ↳ fams.lastIssued already exists`); - } - } - } - - // ── 5c. Add paydayTime (HH:MM) field to fams if missing ── - { - const fc = famsCol || (await getCollection("fams")); - if (fc) { - const hasPaydayTime = fc.fields.some((f: any) => f.name === "paydayTime"); - if (!hasPaydayTime) { - console.log("[migrate] Adding paydayTime field to fams..."); - fc.fields.push({ name: "paydayTime", type: "text" }); - await updateCollection(fc.id, { - name: "fams", - type: "base", - listRule: fc.listRule, - viewRule: fc.viewRule, - createRule: fc.createRule, - updateRule: fc.updateRule, - deleteRule: fc.deleteRule, - fields: fc.fields, - }); - } else { - console.log(` ↳ fams.paydayTime already exists`); - } - } - } - - // ── 5d. Add timezone (IANA name or "auto") field to fams if missing ── - { - const fc = famsCol || (await getCollection("fams")); - if (fc) { - const hasTz = fc.fields.some((f: any) => f.name === "timezone"); - if (!hasTz) { - console.log("[migrate] Adding timezone field to fams..."); - fc.fields.push({ - name: "timezone", - type: "text", - max: 64, - pattern: "", - autogeneratePattern: "", - primaryKey: false, - system: false, - required: false, - unique: false, - hidden: false, - presentable: false, - noDecimal: false, - }); - await updateCollection(fc.id, { - name: "fams", - type: "base", - listRule: fc.listRule, - viewRule: fc.viewRule, - createRule: fc.createRule, - updateRule: fc.updateRule, - deleteRule: fc.deleteRule, - fields: fc.fields, - }); - } else { - console.log(` ↳ fams.timezone already exists`); - } - } - } - - // ── 5e. Backfill fams.timezone = "auto" for any existing fams ── - try { - const t = await auth(); - const res = await fetch( - `${PB_ENDPOINT}/api/collections/fams/records?perPage=200&filter=${encodeURIComponent( - `timezone = "" || timezone = null`, - )}`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const data = await res.json(); - const fams = data?.items || []; - for (const f of fams) { - await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${f.id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ timezone: "auto" }), - }); - } - if (fams.length) { - console.log( - ` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`, - ); - } - } catch (err) { - console.log( - " ↳ timezone backfill skipped:", - err instanceof Error ? err.message : err, - ); - } - - // ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ── - // PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date` - // and just normalise existing records. The frontend reads with .slice(0, 10) either way. - try { - const completionsCol = await getCollection("completions"); - if (completionsCol) { - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - if (data?.items?.length) { - let backfilled = 0; - for (const rec of data.items) { - const plain = rec.date?.slice(0, 10); - if (plain && plain !== rec.date) { - await fetch( - `${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ date: plain }), - }, - ); - backfilled++; - } - } - if (backfilled > 0) - console.log(` ✓ Backfilled ${backfilled} completion dates`); - else console.log(` ↳ completions.date already normalised`); - } - } - } catch (e) { - console.log( - ` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`, - ); - } - - // ── 7. Add memberId field to bonus_configs ── - const bonusConfigsCol = await getCollection("bonus_configs"); - if (bonusConfigsCol) { - const hasMemberId = bonusConfigsCol.fields.some( - (f: any) => f.name === "memberId", - ); - if (!hasMemberId) { - const membersCol = await getCollection("members"); - if (membersCol) { - console.log("[migrate] Adding memberId field to bonus_configs..."); - bonusConfigsCol.fields.push({ - name: "memberId", - type: "relation", - required: false, - collectionId: membersCol.id, - maxSelect: 1, - cascadeDelete: false, - }); - await updateCollection(bonusConfigsCol.id, { - name: "bonus_configs", - type: "base", - listRule: bonusConfigsCol.listRule, - viewRule: bonusConfigsCol.viewRule, - createRule: bonusConfigsCol.createRule, - updateRule: bonusConfigsCol.updateRule, - deleteRule: bonusConfigsCol.deleteRule, - fields: bonusConfigsCol.fields, - }); - } - } else { - console.log(` ↳ bonus_configs.memberId already exists`); - } - } - - // ── 8. (Removed) bonus_configs.phase field was dropped — replaced by status: active|completed. - // Templates now live in a dedicated `bonus_templates` collection (see step 23+). - - // ── 9. Add role + userId fields to members ── - const membersCol = await getCollection("members"); - if (membersCol) { - const hasRole = membersCol.fields.some((f: any) => f.name === "role"); - const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); - if (!hasRole || !hasUserId) { - console.log("[migrate] Adding role/userId fields to members..."); - if (!hasRole) - membersCol.fields.push({ - name: "role", - type: "select", - required: false, - values: ["parent", "child"], - maxSelect: 1, - }); - if (!hasUserId) - membersCol.fields.push({ - name: "userId", - type: "text", - required: false, - }); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.role/userId already exists`); - } - } - - // ── 10. Add email field to members ── - if (membersCol) { - const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); - if (!hasEmail) { - console.log("[migrate] Adding email field to members..."); - membersCol.fields.push({ name: "email", type: "text", required: false }); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.email already exists`); - } - } - - // ── 11. Backfill userId on existing member records ── - if (membersCol) { - try { - const t = await auth(); - const allMembers = await fetch( - `${PB_ENDPOINT}/api/collections/members/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const membersData = await allMembers.json(); - const needBackfill = (membersData?.items || []).filter( - (m: any) => !m.userId, - ); - if (needBackfill.length > 0) { - console.log( - `[migrate] Backfilling userId for ${needBackfill.length} members...`, - ); - const adminsRes = await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const adminsData = await adminsRes.json(); - const adminsByFam = new Map(); - for (const a of adminsData?.items || []) { - const list = adminsByFam.get(a.famId) || []; - list.push(a); - adminsByFam.set(a.famId, list); - } - let count = 0; - for (const m of needBackfill) { - const admins = adminsByFam.get(m.famId) || []; - if (admins.length === 1) { - await fetch( - `${PB_ENDPOINT}/api/collections/members/records/${m.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ userId: admins[0].userId }), - }, - ); - count++; - } - } - if (count > 0) console.log(` ✓ Backfilled ${count} member userIds`); - if (count < needBackfill.length) - console.log( - ` ↳ Skipped ${needBackfill.length - count} members (no unique fam_admin match)`, - ); - } else { - console.log(` ↳ members.userId already backfilled`); - } - } catch (e) { - console.log( - ` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`, - ); - } - } - - // ── 12. Drop userId field from members ── - if (membersCol) { - const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); - if (hasUserId) { - console.log("[migrate] Dropping userId field from members..."); - membersCol.fields = membersCol.fields.filter( - (f: any) => f.name !== "userId", - ); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.userId already removed`); - } - } - - // ── 13. Add name + color fields to fam_admins ── - const adminsCol = await getCollection("fam_admins"); - if (adminsCol) { - const hasName = adminsCol.fields.some((f: any) => f.name === "name"); - const hasColor = adminsCol.fields.some((f: any) => f.name === "color"); - if (!hasName || !hasColor) { - console.log("[migrate] Adding name/color to fam_admins..."); - if (!hasName) - adminsCol.fields.push({ name: "name", type: "text", required: false }); - if (!hasColor) - adminsCol.fields.push({ name: "color", type: "text", required: false }); - await updateCollection(adminsCol.id, { - name: "fam_admins", - type: "base", - listRule: adminsCol.listRule || "", - viewRule: adminsCol.viewRule || "", - createRule: adminsCol.createRule, - updateRule: adminsCol.updateRule, - deleteRule: adminsCol.deleteRule, - fields: adminsCol.fields, - }); - // Backfill name from email prefix - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - for (const a of data?.items || []) { - const patch: Record = {}; - if (!a.name) patch.name = (a.email || "admin").split("@")[0]; - if (!a.color) patch.color = "#6366f1"; - if (Object.keys(patch).length) { - await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify(patch), - }, - ); - } - } - console.log( - ` ✓ Backfilled name/color for ${(data?.items || []).length} admins`, - ); - } else { - console.log(` ↳ fam_admins.name/color already exists`); - } - } - - // ── 14. Drop role field from members ── - if (membersCol) { - const hasRole = membersCol.fields.some((f: any) => f.name === "role"); - if (hasRole) { - console.log("[migrate] Dropping role field from members..."); - membersCol.fields = membersCol.fields.filter( - (f: any) => f.name !== "role", - ); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.role already removed`); - } - } - - // ── 15. Drop email field from members ── - if (membersCol) { - const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); - if (hasEmail) { - console.log("[migrate] Dropping email field from members..."); - membersCol.fields = membersCol.fields.filter( - (f: any) => f.name !== "email", - ); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.email already removed`); - } - } - - // ── 16. Add seasons json field to fams ── - { - const c = await getCollection("fams"); - if (c) { - const hasField = c.fields.some((f: any) => f.name === "seasons"); - if (!hasField) { - console.log("[migrate] Adding seasons to fams..."); - c.fields.push({ name: "seasons", type: "json" }); - await updateCollection(c.id, { - name: "fams", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ fams.seasons already exists`); - } - } - } - - // ── 17. Add seasonIds json field to assigned_chores ── - { - const c = await getCollection("assigned_chores"); - if (c) { - const hasField = c.fields.some((f: any) => f.name === "seasonIds"); - if (!hasField) { - console.log("[migrate] Adding seasonIds to assigned_chores..."); - c.fields.push({ name: "seasonIds", type: "json" }); - await updateCollection(c.id, { - name: "assigned_chores", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ assigned_chores.seasonIds already exists`); - } - } - } - - // ── 18. Create seasons collection if missing ── - { - const existing = await getCollection("seasons"); - if (!existing) { - console.log("[migrate] Creating seasons collection..."); - const t = await auth(); - const famsCol = await getCollection("fams"); - const res = await fetch(`${PB_ENDPOINT}/api/collections`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ - name: "seasons", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - maxSelect: 1, - collectionId: famsCol?.id || "", - }, - { name: "name", type: "text", required: true }, - { name: "color", type: "text" }, - { name: "active", type: "bool" }, - { name: "autoDisable", type: "date" }, - { name: "autoStart", type: "date" }, - ], - }), - }); - if (res.ok) console.log(" ✓ Created seasons collection"); - else - console.log( - ` ↳ seasons collection creation skipped or already exists`, - ); - } else { - console.log(` ↳ seasons collection already exists`); - } - } - - // ── 19. Add active bool to existing seasons collection ── - { - const c = await getCollection("seasons"); - if (c) { - const hasActive = c.fields.some((f: any) => f.name === "active"); - if (!hasActive) { - console.log("[migrate] Adding active bool to seasons..."); - c.fields.push({ name: "active", type: "bool" }); - await updateCollection(c.id, { - name: "seasons", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ seasons.active already exists`); - } - } - } - - // ── 20. Backfill active=true from fams.seasons array ── - { - const t = await auth(); - const fams = await getCollection("fams"); - if (fams) { - const hasSeasonsField = fams.fields.some( - (f: any) => f.name === "seasons", - ); - if (hasSeasonsField) { - console.log( - "[migrate] Backfilling season active flags from fams.seasons...", - ); - const allFams = await fetch( - `${PB_ENDPOINT}/api/collections/fams/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const famData = await allFams.json(); - for (const fam of famData.items || []) { - const activeIds = fam.seasons || []; - if (activeIds.length > 0) { - for (const sid of activeIds) { - await fetch( - `${PB_ENDPOINT}/api/collections/seasons/records/${sid}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ active: true }), - }, - ); - } - } - } - console.log(` ↳ backfilled ${famData.items?.length || 0} fams`); - } else { - console.log(` ↳ fams.seasons already removed`); - } - } - } - - // ── 21. Remove seasons field from fams ── - { - const c = await getCollection("fams"); - if (c) { - const hasField = c.fields.some((f: any) => f.name === "seasons"); - if (hasField) { - console.log("[migrate] Removing seasons field from fams..."); - c.fields = c.fields.filter((f: any) => f.name !== "seasons"); - await updateCollection(c.id, { - name: "fams", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ fams.seasons already removed`); - } - } - } - - // ── 22. Convert rewards.claimed boolean to status select field ── - { - const c = await getCollection("rewards"); - if (c) { - const hasClaimedField = c.fields.some((f: any) => f.name === "claimed"); - const hasStatusField = c.fields.some((f: any) => f.name === "status"); - if (hasClaimedField && !hasStatusField) { - console.log("[migrate] Converting rewards.claimed to status field..."); - - // Fetch all rewards to backfill status - const t = await auth(); - let page = 1; - let total = 0; - while (true) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records?page=${page}&perPage=100`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await res.json(); - for (const r of data.items || []) { - const status = r.claimed ? "claimed" : "unclaimed"; - await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ status }), - }, - ); - total++; - } - if (!data.items || data.items.length < 100) break; - page++; - } - console.log(` ↳ backfilled ${total} rewards with status`); - - // Remove claimed field and add status + requestedAt fields - c.fields = c.fields.filter((f: any) => f.name !== "claimed"); - if (!c.fields.some((f: any) => f.name === "status")) { - c.fields.push({ - name: "status", - type: "select", - required: true, - values: ["unclaimed", "requested", "claimed"], - maxSelect: 1, - }); - } - if (!c.fields.some((f: any) => f.name === "requestedAt")) { - c.fields.push({ name: "requestedAt", type: "date", required: false }); - } - await updateCollection(c.id, { - name: "rewards", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else if (!hasClaimedField) { - console.log(` ↳ rewards.claimed already converted to status`); - } - } - } - - // ── 23. Create bonus_templates collection if missing ── - { - const existing = await getCollection("bonus_templates"); - if (!existing) { - const famsCol = await getCollection("fams"); - if (!famsCol) throw new Error("fams collection not found"); - console.log("[migrate] Creating bonus_templates collection..."); - await createCollection({ - name: "bonus_templates", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsCol.id, - maxSelect: 1, - cascadeDelete: false, - }, - { name: "name", type: "text", required: true }, - { name: "description", type: "text", required: false }, - { - name: "target", - type: "select", - required: true, - values: ["individual", "competitive", "collaborative"], - maxSelect: 1, - }, - { - name: "type", - type: "select", - required: true, - values: ["threshold", "count", "manual"], - maxSelect: 1, - }, - { - name: "occurrence", - type: "select", - required: true, - values: ["recurring", "once"], - maxSelect: 1, - }, - { - name: "rewardType", - type: "select", - required: true, - values: ["points", "cash", "prize"], - maxSelect: 1, - }, - { name: "rewardValue", type: "text", required: true }, - { name: "criteriaValue", type: "number", required: false }, - { - name: "period", - type: "select", - required: false, - values: ["schedule", "daily", "weekly", "monthly"], - maxSelect: 1, - }, - ], - }); - } else { - console.log(` ↳ bonus_templates already exists`); - } - } - - // ── 24. Migrate phase='template' bonus_configs → bonus_templates, then delete originals ── - { - const configsCol = await getCollection("bonus_configs"); - const templatesCol = await getCollection("bonus_templates"); - if (configsCol && templatesCol) { - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - const templateRecords = (data?.items || []).filter( - (r: any) => r.phase === "template", - ); - if (templateRecords.length > 0) { - console.log( - `[migrate] Moving ${templateRecords.length} templates from bonus_configs → bonus_templates...`, - ); - for (const rec of templateRecords) { - const { - id, - phase, - status, - completedAt, - memberId, - created, - updated, - collectionId, - expand, - ...fields - } = rec; - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_templates/records`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify(fields), - }, - ); - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records/${id}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${t}` }, - }, - ); - } - console.log(` ✓ Moved ${templateRecords.length} bonus templates`); - } else { - console.log(` ↳ no phase=template bonus_configs to migrate`); - } - } - } - - // ── 25. Drop phase/completedAt from bonus_configs; status → active|completed ── - { - const c = await getCollection("bonus_configs"); - if (c) { - const hasPhase = c.fields.some((f: any) => f.name === "phase"); - const hasCompletedAt = c.fields.some( - (f: any) => f.name === "completedAt", - ); - const statusField = c.fields.find((f: any) => f.name === "status"); - const needsStatusUpdate = - statusField && !statusField.values.includes("completed"); - if (hasPhase || hasCompletedAt || needsStatusUpdate) { - console.log( - "[migrate] Restructuring bonus_configs (drop phase/completedAt, status → active|completed)...", - ); - if (statusField && needsStatusUpdate) - statusField.values = ["active", "completed"]; - c.fields = c.fields.filter( - (f: any) => f.name !== "phase" && f.name !== "completedAt", - ); - await updateCollection(c.id, { - name: "bonus_configs", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - let updated = 0; - for (const rec of data?.items || []) { - const completed = - rec.phase === "completed" || rec.status === "archived"; - const newStatus = completed ? "completed" : "active"; - if (rec.status !== newStatus) { - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ status: newStatus }), - }, - ); - updated++; - } - } - console.log( - ` ✓ Updated ${updated} bonus_configs to status=active|completed`, - ); - } else { - console.log(` ↳ bonus_configs already restructured`); - } - } - } - - // ── 7. Add todo fields to assigned_chores if missing ── - const assignedCol = await getCollection("assigned_chores"); - if (assignedCol) { - let needsUpdate = false; - - // 7a. Make templateId non-required (todos don't use templates) - const tplField = assignedCol.fields.find( - (f: any) => f.name === "templateId", - ); - if (tplField && tplField.required) { - console.log( - "[migrate] Making assigned_chores.templateId non-required...", - ); - tplField.required = false; - needsUpdate = true; - } - - // 7b. Add isTodo, startDate, completeBy fields - const hasIsTodo = assignedCol.fields.some((f: any) => f.name === "isTodo"); - if (!hasIsTodo) { - console.log("[migrate] Adding todo fields to assigned_chores..."); - assignedCol.fields.push( - { name: "isTodo", type: "bool" }, - { name: "startDate", type: "text" }, - { name: "completeBy", type: "text" }, - ); - needsUpdate = true; - } else { - console.log(` ↳ assigned_chores.isTodo already exists`); - } - - if (needsUpdate) { - await updateCollection(assignedCol.id, { - name: "assigned_chores", - type: "base", - listRule: assignedCol.listRule, - viewRule: assignedCol.viewRule, - createRule: assignedCol.createRule, - updateRule: assignedCol.updateRule, - deleteRule: assignedCol.deleteRule, - fields: assignedCol.fields, - }); - } - } else { - console.log( - ` ↳ assigned_chores collection not found (will be created by seed)`, - ); - } - - // ── 8. Add completedAt timestamp to completions ── - const complCol = await getCollection("completions"); - if (complCol) { - const hasCompletedAt = complCol.fields.some( - (f: any) => f.name === "completedAt", - ); - if (!hasCompletedAt) { - console.log("[migrate] Adding completions.completedAt..."); - complCol.fields.push({ - name: "completedAt", - type: "date", - required: false, - hidden: false, - }); - await updateCollection(complCol.id, { - name: "completions", - type: "base", - listRule: complCol.listRule, - viewRule: complCol.viewRule, - createRule: complCol.createRule, - updateRule: complCol.updateRule, - deleteRule: complCol.deleteRule, - fields: complCol.fields, - }); - console.log(" ✓ completions.completedAt added"); - } else { - console.log(` ↳ completions.completedAt already exists`); - } - } else { - console.log( - ` ↳ completions collection not found (will be created by seed)`, - ); - } - - // ── 9. Create chat collections (messages + chat_typing) ── - const famsColChat = await getCollection("fams"); - if (famsColChat) { - // 9a. messages - const msgsCol = await getCollection("messages"); - if (!msgsCol) { - console.log("[migrate] Creating messages collection..."); - await createCollection({ - name: "messages", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsColChat.id, - maxSelect: 1, - cascadeDelete: true, - }, - { name: "authorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, - { name: "authorId", type: "text", required: true }, - { name: "authorName", type: "text", required: true }, - { name: "authorColor", type: "text", required: false }, - { name: "content", type: "text", required: true }, - { name: "createdAt", type: "date", required: false }, - { name: "clientId", type: "text", required: false, max: 64 }, - ], - }); - } else { - console.log(` ↳ messages already exists`); - // PB 0.39 doesn't auto-add createdAt to API-created collections, and - // older stores may predate it. Chat filters/sorts on createdAt, so - // ensure the field exists even if the collection was created without it. - const msgsFields = msgsCol.fields?.map((f: any) => f.name) || []; - let msgsChanged = false; - if (!msgsFields.includes("createdAt")) { - console.log(" ↳ adding missing createdAt field to messages..."); - msgsCol.fields.push({ - name: "createdAt", - type: "date", - required: false, - min: "", - max: "", - }); - msgsChanged = true; - } - if (!msgsFields.includes("clientId")) { - console.log(" ↳ adding missing clientId field to messages..."); - msgsCol.fields.push({ name: "clientId", type: "text", required: false, max: 64 }); - msgsChanged = true; - } - await updateCollection("messages", { - listRule: "", - viewRule: "", - fields: msgsCol.fields, - }); - } - - // 9b. chat_typing (transient presence rows, one per actor) - const typingCol = await getCollection("chat_typing"); - if (!typingCol) { - console.log("[migrate] Creating chat_typing collection..."); - await createCollection({ - name: "chat_typing", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsColChat.id, - maxSelect: 1, - cascadeDelete: true, - }, - { name: "actorId", type: "text", required: true }, - { name: "actorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, - { name: "authorName", type: "text", required: true }, - { name: "authorColor", type: "text", required: false }, - { name: "typing", type: "bool", required: false }, - ], - }); - } else { - console.log(` ↳ chat_typing already exists`); - await updateCollection("chat_typing", { listRule: "", viewRule: "" }); - } - } else { - 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. SvelteKit writes - // as the authenticated user, so 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). - // - // Admin-only collections additionally require role='parent'; child-accessible - // collections (completions toggle, reward claim, chat) are famId-scoped only. - { - const PARENT_WRITE = - "@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'"; - const PARENT_SCOPED = "famId = @request.auth.famId && @request.auth.role = 'parent'"; - const FAM_WRITE = "@request.body.famId = @request.auth.famId"; - const FAM_SCOPED = "famId = @request.auth.famId"; - const ADMIN_ONLY = [ - "chore_templates", - "assigned_chores", - "bonus_templates", - "bonus_configs", - "settings", - "weekly_history", - "monthly_bonuses", - "seasons", - ]; - const CHILD_ACCESSIBLE = ["completions", "rewards", "messages", "chat_typing"]; - for (const name of [...ADMIN_ONLY, ...CHILD_ACCESSIBLE]) { - const c = await getCollection(name); - if (!c) continue; - const isParent = ADMIN_ONLY.includes(name); - const write = isParent ? PARENT_WRITE : FAM_WRITE; - const scoped = isParent ? PARENT_SCOPED : FAM_SCOPED; - if (c.createRule === write && c.updateRule === scoped && c.deleteRule === scoped) continue; - await updateCollection(c.id, { - name, - type: c.type, - listRule: c.listRule, - viewRule: c.viewRule, - createRule: write, - updateRule: scoped, - deleteRule: scoped, - fields: c.fields, - }); - console.log(` ↳ Locked ${name} write rules (${isParent ? "parent" : "fam"} scoping)`); - } - } - - // ── 28b. Allow each user to READ + 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 are enabled so both parents and children can - // load their fam via their own token; create/delete stay superuser-only. - { - const c = await getCollection("fams"); - if (c) { - const wantList = c.listRule !== "id = @request.auth.famId"; - const wantView = c.viewRule !== "id = @request.auth.famId"; - const wantUpdate = c.updateRule !== "id = @request.auth.famId"; - if (wantList || wantView || wantUpdate) { - await updateCollection(c.id, { - name: "fams", - type: c.type, - listRule: "id = @request.auth.famId", - viewRule: "id = @request.auth.famId", - createRule: c.createRule, - updateRule: "id = @request.auth.famId", - deleteRule: c.deleteRule, - fields: c.fields, - }); - console.log(" ↳ fams read+update 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. otp: OTP store (superuser-only) ── - // Holds the rotating one-time code and the OTP-issue timestamp used for the - // 20-minute join 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. userId links to the users auth record so each child's config is - // uniquely addressable. (Display colour lives on users.color, not here.) - { - if (!(await getCollection("otp"))) { - 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 otp collection..."); - await createCollection({ - name: "otp", - 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: "updatedAt", type: "text", required: false }, - ], - }); - } else { - console.log(" ↳ otp 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"); -} +} \ No newline at end of file diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index 9881297..15a8775 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -1,9 +1,14 @@ // Single source of truth for the PocketBase schema + field builders. -// Consumed by BOTH proxy/src/migrate.ts (idempotent bootstrap) and -// proxy/scripts/seed.ts (fresh-store seed) so the schema isn't duplicated. +// Consumed by frontend/src/lib/server/migrate.ts (idempotent bootstrap) so the +// schema isn't duplicated. // // Relations reference collections by name; the `ids` map maps collection // name -> runtime id (filled as each collection is created). +// +// NOTE: the native `users` auth collection and the superuser-only `otp` +// collection are NOT in SCHEMA_PLAN — they're applied separately in +// migrate.ts (users is PB's built-in auth model; `otp` needs null rules, +// which the `col()` builder can't express). Everything else lives here. export interface FieldDef { name: string; @@ -128,7 +133,10 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ uniqueText("slug"), text("stripeCustomerId"), jsonField("featureFlags"), - jsonField("seasons"), + number("payday"), + text("lastIssued"), + text("paydayTime"), + text("timezone"), ], { listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM }, )(ids), @@ -156,7 +164,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ { name: "settings", build: (ids) => - col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], { + col("settings", [rel("famId", ids.fams, true), text("webhookUrl"), bool("simulateEow")], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, @@ -246,6 +254,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ // Explicit createdAt: PB 0.39 does NOT auto-add createdAt to // API-created collections (0.25 did). Chat filters/sorts on it. date("createdAt"), + text("clientId"), ], { createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, @@ -302,6 +311,9 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ number("value", true), text("customName"), jsonField("seasonIds"), + bool("isTodo"), + text("startDate"), + text("completeBy"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED,