From 57ef30d3a7d5da3ab951ea973f5249e75d5a6249 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Wed, 29 Jul 2026 09:41:52 +0100 Subject: [PATCH] update ui and other frontend updates --- .gitignore | 1 + AGENTS.md | 93 +- MEMORY.md | 78 +- frontend/.svelte-kit/ambient.d.ts | 3 - frontend/.svelte-kit/env.d.ts | 11 - frontend/.svelte-kit/generated/client/app.js | 31 - .../.svelte-kit/generated/client/matchers.js | 1 - .../.svelte-kit/generated/client/nodes/0.js | 1 - .../.svelte-kit/generated/client/nodes/1.js | 1 - .../.svelte-kit/generated/client/nodes/2.js | 1 - .../.svelte-kit/generated/client/nodes/3.js | 1 - frontend/.svelte-kit/generated/root.js | 3 - frontend/.svelte-kit/generated/root.svelte | 75 -- .../.svelte-kit/generated/server/internal.js | 52 - frontend/.svelte-kit/non-ambient.d.ts | 44 - frontend/.svelte-kit/tsconfig.json | 60 - .../.svelte-kit/types/route_meta_data.json | 6 - .../.svelte-kit/types/src/routes/$types.d.ts | 23 - .../types/src/routes/debug/$types.d.ts | 24 - frontend/src/env.ts | 3 +- frontend/src/hooks.server.ts | 16 + frontend/src/lib/client/api.ts | 39 + frontend/src/lib/components/Accordion.svelte | 36 + frontend/src/lib/components/Button.svelte | 49 + frontend/src/lib/components/Card.svelte | 31 + frontend/src/lib/components/CardGrid.svelte | 21 + frontend/src/lib/components/Footer.svelte | 13 + frontend/src/lib/components/Sidebar.svelte | 134 ++ frontend/src/lib/components/TopNav.svelte | 30 + frontend/src/lib/components/ViewHeader.svelte | 71 ++ frontend/src/lib/components/icons.ts | 11 + frontend/src/lib/components/index.ts | 8 + frontend/src/lib/crypto.ts | 9 + frontend/src/lib/pocketbase.ts | 15 +- frontend/src/lib/server/auth.ts | 87 ++ frontend/src/lib/server/hono.ts | 111 ++ frontend/src/lib/server/pb-admin.ts | 45 + frontend/src/lib/stores/fam.svelte.ts | 155 +++ frontend/src/lib/types.ts | 142 +++ frontend/src/routes/[fam]/+error.svelte | 18 + frontend/src/routes/[fam]/+layout.server.ts | 4 + frontend/src/routes/[fam]/+layout.svelte | 69 + frontend/src/routes/[fam]/+page.server.ts | 20 + frontend/src/routes/[fam]/+page.svelte | 175 +++ .../routes/[fam]/[username]/+page.server.ts | 122 ++ .../src/routes/[fam]/[username]/+page.svelte | 813 ++++++++++++ .../[fam]/[username]/bonuses/+page.server.ts | 186 +++ .../[fam]/[username]/bonuses/+page.svelte | 1071 ++++++++++++++++ .../[fam]/[username]/chores/+page.server.ts | 140 +++ .../[fam]/[username]/chores/+page.svelte | 402 ++++++ .../[username]/preferences/+page.server.ts | 99 ++ .../[fam]/[username]/preferences/+page.svelte | 99 ++ .../[fam]/[username]/rewards/+page.server.ts | 27 + .../[fam]/[username]/rewards/+page.svelte | 137 ++ .../[fam]/[username]/settings/+page.server.ts | 57 + .../[fam]/[username]/settings/+page.svelte | 171 +++ frontend/src/routes/admin/+page.server.ts | 23 + frontend/src/routes/admin/+page.svelte | 37 + .../src/routes/join/[code]/+page.server.ts | 24 + frontend/src/routes/join/[code]/+page.svelte | 29 + .../join/[code]/[member]/+page.server.ts | 24 + .../routes/join/[code]/[member]/+page.svelte | 24 + frontend/src/routes/login/+page.server.ts | 33 + frontend/src/routes/login/+page.svelte | 35 + frontend/src/routes/logout/+page.server.ts | 14 + frontend/src/routes/signup/+page.server.ts | 35 + frontend/src/routes/signup/+page.svelte | 47 + proxy/.gitignore | 1 + proxy/dist/index.d.ts | 2 - proxy/dist/index.d.ts.map | 1 - proxy/dist/index.js | 35 - proxy/dist/index.js.map | 1 - proxy/package.json | 3 +- proxy/scripts/seed.ts | 314 +++++ proxy/scripts/test-admin.ts | 99 ++ proxy/scripts/test-auth.ts | 63 + proxy/src/index.ts | 1112 ++++++++++++++++- proxy/src/migrate.ts | 557 +++++++++ proxy/src/pb.ts | 94 ++ 79 files changed, 7323 insertions(+), 429 deletions(-) delete mode 100644 frontend/.svelte-kit/ambient.d.ts delete mode 100644 frontend/.svelte-kit/env.d.ts delete mode 100644 frontend/.svelte-kit/generated/client/app.js delete mode 100644 frontend/.svelte-kit/generated/client/matchers.js delete mode 100644 frontend/.svelte-kit/generated/client/nodes/0.js delete mode 100644 frontend/.svelte-kit/generated/client/nodes/1.js delete mode 100644 frontend/.svelte-kit/generated/client/nodes/2.js delete mode 100644 frontend/.svelte-kit/generated/client/nodes/3.js delete mode 100644 frontend/.svelte-kit/generated/root.js delete mode 100644 frontend/.svelte-kit/generated/root.svelte delete mode 100644 frontend/.svelte-kit/generated/server/internal.js delete mode 100644 frontend/.svelte-kit/non-ambient.d.ts delete mode 100644 frontend/.svelte-kit/tsconfig.json delete mode 100644 frontend/.svelte-kit/types/route_meta_data.json delete mode 100644 frontend/.svelte-kit/types/src/routes/$types.d.ts delete mode 100644 frontend/.svelte-kit/types/src/routes/debug/$types.d.ts create mode 100644 frontend/src/hooks.server.ts create mode 100644 frontend/src/lib/client/api.ts create mode 100644 frontend/src/lib/components/Accordion.svelte create mode 100644 frontend/src/lib/components/Button.svelte create mode 100644 frontend/src/lib/components/Card.svelte create mode 100644 frontend/src/lib/components/CardGrid.svelte create mode 100644 frontend/src/lib/components/Footer.svelte create mode 100644 frontend/src/lib/components/Sidebar.svelte create mode 100644 frontend/src/lib/components/TopNav.svelte create mode 100644 frontend/src/lib/components/ViewHeader.svelte create mode 100644 frontend/src/lib/components/icons.ts create mode 100644 frontend/src/lib/components/index.ts create mode 100644 frontend/src/lib/crypto.ts create mode 100644 frontend/src/lib/server/auth.ts create mode 100644 frontend/src/lib/server/hono.ts create mode 100644 frontend/src/lib/server/pb-admin.ts create mode 100644 frontend/src/lib/stores/fam.svelte.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/routes/[fam]/+error.svelte create mode 100644 frontend/src/routes/[fam]/+layout.server.ts create mode 100644 frontend/src/routes/[fam]/+layout.svelte create mode 100644 frontend/src/routes/[fam]/+page.server.ts create mode 100644 frontend/src/routes/[fam]/+page.svelte create mode 100644 frontend/src/routes/[fam]/[username]/+page.server.ts create mode 100644 frontend/src/routes/[fam]/[username]/+page.svelte create mode 100644 frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts create mode 100644 frontend/src/routes/[fam]/[username]/bonuses/+page.svelte create mode 100644 frontend/src/routes/[fam]/[username]/chores/+page.server.ts create mode 100644 frontend/src/routes/[fam]/[username]/chores/+page.svelte create mode 100644 frontend/src/routes/[fam]/[username]/preferences/+page.server.ts create mode 100644 frontend/src/routes/[fam]/[username]/preferences/+page.svelte create mode 100644 frontend/src/routes/[fam]/[username]/rewards/+page.server.ts create mode 100644 frontend/src/routes/[fam]/[username]/rewards/+page.svelte create mode 100644 frontend/src/routes/[fam]/[username]/settings/+page.server.ts create mode 100644 frontend/src/routes/[fam]/[username]/settings/+page.svelte create mode 100644 frontend/src/routes/admin/+page.server.ts create mode 100644 frontend/src/routes/admin/+page.svelte create mode 100644 frontend/src/routes/join/[code]/+page.server.ts create mode 100644 frontend/src/routes/join/[code]/+page.svelte create mode 100644 frontend/src/routes/join/[code]/[member]/+page.server.ts create mode 100644 frontend/src/routes/join/[code]/[member]/+page.svelte create mode 100644 frontend/src/routes/login/+page.server.ts create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/src/routes/logout/+page.server.ts create mode 100644 frontend/src/routes/signup/+page.server.ts create mode 100644 frontend/src/routes/signup/+page.svelte create mode 100644 proxy/.gitignore delete mode 100644 proxy/dist/index.d.ts delete mode 100644 proxy/dist/index.d.ts.map delete mode 100644 proxy/dist/index.js delete mode 100644 proxy/dist/index.js.map create mode 100644 proxy/scripts/seed.ts create mode 100644 proxy/scripts/test-admin.ts create mode 100644 proxy/scripts/test-auth.ts create mode 100644 proxy/src/migrate.ts create mode 100644 proxy/src/pb.ts diff --git a/.gitignore b/.gitignore index 7f2dbf3..d67fcf0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules /.svelte-kit /build + # OS .DS_Store Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index 6462ca9..cddbb45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,15 @@ ## Auth -| Role | Auth | Session | -| ---------------- | -------------------------- | --------------------------- | -| Super admin (me) | PB email+pass | 24hr JWT | -| Fam admin | PB email+pass | 24hr JWT, scoped to own fam | -| Member | Invite code + device token | localStorage, no expiry | +| Role | Auth | Session | Record in | +| ---------------- | -------------------------- | --------------------------- | -------------------- | +| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | +| Member (child) | Invite code + device token | `device_token` cookie only | `members` | + +- **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`. +- **Members** (children) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie. +- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard). Not an app role. +- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session cookie — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. ## PB Collections (all scoped by `famId`) @@ -42,9 +46,15 @@ / Landing (SaaS marketing) /admin Admin panel - statistic dashboard, and any donations made /join/:code Member invite code +/join/:code/:member Member invite with pre-selected member /{fam} Fam dashboard /{fam}/admin Admin panel -/{fam}/:username Member kanban (?token= for auth) +/{fam}/:username Member kanban & admin dashboard (role determined by session) +/{fam}/:username/preferences User preferences (admin→fam_admins, member→members) +/{fam}/:username/settings Family admin settings (session required) +/{fam}/:username/chores Chore templates & assignment grid +/{fam}/:username/rewards Rewards overview +/{fam}/:username/bonuses Bonus configs & evaluation /api/* Hono proxy (webhooks, CRON) ``` @@ -58,11 +68,82 @@ - **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface - **UI reactivity:** Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE +## Env Vars (SvelteKit 3.0.0-next.4) + +Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`: + +```ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; +export const variables = defineEnvVars({ + DEBUG_RECORD_ID: {}, + SERVER_IP: {public: true}, + PB_PORT: {public: true} +}); +``` + +Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must: +1. Add it to `.env` with `PUBLIC_` prefix +2. Add it to `frontend/src/env.ts` with `{public: true}` + +The actual values come from `.env` (symlinked from project root at `frontend/.env -> ../.env`). Server-only env vars (no `{public}` flag) are available via `$app/env/private` but only in server modules. + +## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore) + +**Do NOT do this:** +```svelte + +let items = $state(data.items); +$effect(() => { + if (data.items?.length) items = data.items; + if (famStore.initialized && famStore.items.length) items = famStore.items; +}); +``` + +**Do this instead:** +```svelte + +let items = $state(famStore.initialized ? famStore.items : (data.items || [])); +``` + +**Form action callbacks must call `famStore.applyRecord()`:** +```ts +// In use:enhance callback: +famStore.applyRecord('collection_name', record, 'create' | 'update' | 'delete'); +``` + +The `famStore` has reactive `$state` properties and an `applyRecord()` method designed for instant UI feedback from form actions. Calling `applyRecord()` mutates the store directly — the UI updates immediately without waiting for PB SSE. The SSE subscription is a backup for multi-user sync only. Do NOT add `$effect` watchers to bridge the gap between form actions and reactive state. + +## UI Component Conventions + +All admin and member pages use the following pattern: + +```svelte + + + + + + +``` + +- **Layout shell** lives in `[fam]/+layout.svelte` — Sidebar, TopNav, Footer. All fam-scoped routes inherit it. +- **Sidebar** role-aware: shows admin CTAs on `/admin/*`, member CTAs on `/[username]/*`. +- **TopNav** has `announcement` (center slot) and `actions` (right slot). +- **`Card` `cols` prop**: `1 | 2 | 3` — spans that many columns in the 3-column `CardGrid`. +- **`Button`** for all CTAs: ``. +- **`Accordion`** for expand/collapse sections (settings, logs). +- **Icons**: defined as SVG strings in `lib/components/icons.ts`. No icon library dependency. +- **Components** live in `frontend/src/lib/components/` and are re-exported from `index.ts`. + ## Conventions - Every collection query includes `famId = @request.auth.famId` filter - Super admin bypasses famId filter (access via PB admin API) - `deviceToken` stored as SHA-256 hash; never log raw tokens +- **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 +- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix - `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars - `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template - Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase) diff --git a/MEMORY.md b/MEMORY.md index 24cbed2..7ec506c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,6 +1,76 @@ # FamChore v2 — Development Memory -## Architecture Decisions +## UI Component Architecture (Jul 2026) + +### Layout Hierarchy +``` ++layout.svelte ← global styles, meta, favicon +├── /login, /signup, /join/* ← auth pages (no shell) +└── [fam]/+layout.svelte ← Shell: Sidebar + TopNav + Footer + claim toast + ├── [fam]/+page.svelte ← fam dashboard + ├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban + ├── [fam]/{username}/chores/+page.svelte ← parent only + ├── [fam]/{username}/rewards/+page.svelte ← parent only + ├── [fam]/{username}/bonuses/+page.svelte ← parent only + ├── [fam]/{username}/settings/+page.svelte ← parent only + └── [fam]/{username}/preferences/+page.svelte ← both roles +``` + +### Sidebar (collapsible to mini-mode) +- Header: app name (FamChore) +- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses +- Member CTAs: Dashboard, Preferences +- Footer: family name, Settings (admin only), Log out +- Role-aware: items differ based on admin vs member route + +### TopNav +- Slot `announcement` (center) — system/family messages +- Slot `actions` (right) — user status, claim/message + +### Page Content +- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort) +- `CardGrid` — 3-column grid, Cards span columns via `cols` prop +- `Card` — 1/2/3 col span, micro-layout per page +- `Accordion` — for settings / log sections +- `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg) + +### Components (frontend/src/lib/components/) +- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte` +- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte` +- `Button.svelte`, `Accordion.svelte` +- `icons.ts` — SVG icon strings (no icon library dep) + +### Role-Based Auth (Jul 2026) + +- Members have `role` field (`'parent' | 'child'`, added to `members` collection) +- Parents authenticate via email/password (PB session JWT), children via device token +- `/api/admin/signup` now creates a member record for the parent with `role: 'parent'` +- `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info +- `/api/members/verify-token` returns `role` for the frontend +- `requireAdmin` middleware unchanged (still checks `fam_admins`) +- Frontend sidebar is role-aware: `isParent = session !== null` +- **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc. + +### Routes (Jul 2026) + +``` +/ Landing (SaaS marketing) +/signup Signup (creates parent user + member) +/login Login (returns member info, redirects to /{fam}/{memberName}) +/join/:code Member invite (child) +/join/:code/:member Member invite with pre-selected name +/admin Super admin dashboard (unchanged) +/{fam} Fam dashboard +/{fam}/{username} Parent → admin overview, Child → kanban +/{fam}/{username}/chores Parent: chore management +/{fam}/{username}/rewards Parent: reward ledger +/{fam}/{username}/bonuses Parent: bonus configs +/{fam}/{username}/settings Parent: family settings +/{fam}/{username}/preferences Both: edit name/color +/api/* Hono proxy +``` + +### Architecture Decisions ### 2026-06-23 — Monorepo & Docker Setup @@ -16,3 +86,9 @@ - **All data operations** (reads and writes) go through the Hono proxy, never directly to PB SDK. - **UI reactivity** is purely Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE. - The `/debug` page's PB SDK `subscribe()` was experimental only; final apps fetch via Hono proxy and update Svelte state reactively. + +### 2026-07-28 — Auth Bug: Join Flow Set Session Cookie for Children + +- **Bug**: Both `join/[code]/+page.server.ts` and `join/[code]/[member]/+page.server.ts` called `setSessionCookie()` with `userId: memberId` (the child's PB record ID). This made `event.locals.session` truthy for children, causing `[username]/+page.server.ts` to enter the admin branch and call `hono.admin.*` endpoints. The proxy's `requireAdmin` checked `fam_admins` for the child's member ID (which doesn't exist) and returned 401. +- **Fix**: Removed `setSessionCookie()` from both join pages. Children only get a `device_token` cookie. The session cookie is only for email/password-authenticated parents, set by `/login` and `/signup`. +- **Lesson**: Children must never get a session cookie. The auth table in AGENTS.md says "Member → device token, no expiry" — the code must match. diff --git a/frontend/.svelte-kit/ambient.d.ts b/frontend/.svelte-kit/ambient.d.ts deleted file mode 100644 index 9f93d4c..0000000 --- a/frontend/.svelte-kit/ambient.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// this file is generated — do not edit it - -/// \ No newline at end of file diff --git a/frontend/.svelte-kit/env.d.ts b/frontend/.svelte-kit/env.d.ts deleted file mode 100644 index 4148815..0000000 --- a/frontend/.svelte-kit/env.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file is generated from ../src/env.ts. -// See https://svelte.dev/docs/kit/environment-variables for more information - -declare module '$app/env/private' { - export const DEBUG_RECORD_ID: string; -} - -declare module '$app/env/public' { - export const SERVER_IP: string; - export const PB_PORT: string; -} \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/client/app.js b/frontend/.svelte-kit/generated/client/app.js deleted file mode 100644 index 627698c..0000000 --- a/frontend/.svelte-kit/generated/client/app.js +++ /dev/null @@ -1,31 +0,0 @@ -export { matchers } from './matchers.js'; - -export const nodes = [ - () => import('./nodes/0'), - () => import('./nodes/1'), - () => import('./nodes/2'), - () => import('./nodes/3') -]; - -export const server_loads = []; - -export const dictionary = { - "/": [2], - "/debug": [~3] - }; - -export const hooks = { - handleError: (({ error }) => { console.error(error) }), - - reroute: (() => {}), - transport: {} -}; - -export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); -export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); - -export const hash = false; - -export const decode = (type, value) => decoders[type](value); - -export { default as root } from '../root.js'; \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/client/matchers.js b/frontend/.svelte-kit/generated/client/matchers.js deleted file mode 100644 index f6bd30a..0000000 --- a/frontend/.svelte-kit/generated/client/matchers.js +++ /dev/null @@ -1 +0,0 @@ -export const matchers = {}; \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/client/nodes/0.js b/frontend/.svelte-kit/generated/client/nodes/0.js deleted file mode 100644 index fed1375..0000000 --- a/frontend/.svelte-kit/generated/client/nodes/0.js +++ /dev/null @@ -1 +0,0 @@ -export { default as component } from "../../../../src/routes/+layout.svelte"; \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/client/nodes/1.js b/frontend/.svelte-kit/generated/client/nodes/1.js deleted file mode 100644 index 94b97ac..0000000 --- a/frontend/.svelte-kit/generated/client/nodes/1.js +++ /dev/null @@ -1 +0,0 @@ -export { default as component } from "../../../../../node_modules/.pnpm/@sveltejs+kit@3.0.0-next.4_@sveltejs+vite-plugin-svelte@7.1.2_svelte@5.56.3_vite@8.0.16_831df308509456bf75377b19fdb5b8d4/node_modules/@sveltejs/kit/src/runtime/components/error.svelte"; \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/client/nodes/2.js b/frontend/.svelte-kit/generated/client/nodes/2.js deleted file mode 100644 index 1cb4f85..0000000 --- a/frontend/.svelte-kit/generated/client/nodes/2.js +++ /dev/null @@ -1 +0,0 @@ -export { default as component } from "../../../../src/routes/+page.svelte"; \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/client/nodes/3.js b/frontend/.svelte-kit/generated/client/nodes/3.js deleted file mode 100644 index 96d0a51..0000000 --- a/frontend/.svelte-kit/generated/client/nodes/3.js +++ /dev/null @@ -1 +0,0 @@ -export { default as component } from "../../../../src/routes/debug/+page.svelte"; \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/root.js b/frontend/.svelte-kit/generated/root.js deleted file mode 100644 index 4d1e892..0000000 --- a/frontend/.svelte-kit/generated/root.js +++ /dev/null @@ -1,3 +0,0 @@ -import { asClassComponent } from 'svelte/legacy'; -import Root from './root.svelte'; -export default asClassComponent(Root); \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/root.svelte b/frontend/.svelte-kit/generated/root.svelte deleted file mode 100644 index 0e3fe10..0000000 --- a/frontend/.svelte-kit/generated/root.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - - -{#snippet pyramid(depth)} - {@const Pyramid = constructors[depth]} - {#snippet failed(error)} - {@const ErrorPage = errors[depth]} - - {/snippet} - - {#if constructors[depth + 1]} - {@const d = data[depth]} - - - {@render pyramid(depth + 1)} - - {:else} - {@const d = data[depth]} - - - {/if} - -{/snippet} - -{@render pyramid(0)} - -{#if mounted} - - {#if navigated} - {title} - {/if} - -{/if} \ No newline at end of file diff --git a/frontend/.svelte-kit/generated/server/internal.js b/frontend/.svelte-kit/generated/server/internal.js deleted file mode 100644 index 03ab82b..0000000 --- a/frontend/.svelte-kit/generated/server/internal.js +++ /dev/null @@ -1,52 +0,0 @@ - -import root from '../root.js'; -import { set_building, set_prerendering } from '$app/env/internal'; -import { set_assets } from '$app/paths/internal/server'; -import { set_manifest, set_read_implementation } from '__sveltekit/server'; -import { set_env } from '__sveltekit/env'; - -export const options = { - app_template_contains_nonce: false, - async: true, - csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, - csrf_check_origin: false, - csrf_trusted_origins: ["*"], - embedded: false, - hash_routing: false, - hooks: null, // added lazily, via `get_hooks` - link_header_preload: false, - root, - service_worker: false, - service_worker_options: undefined, - server_error_boundaries: true, - templates: { - app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t" + body + "\n\t\n\n", - error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t\n\t\t\t" + status + "\n\t\t\t\n\t\t\t\t" + message + "\n\t\t\t\n\t\t\n\t\n\n" - }, - version_hash: "p7lyxi" -}; - -export async function get_hooks() { - let handle; - let handleFetch; - let handleError; - let handleValidationError; - let init; - - - let reroute; - let transport; - - - return { - handle, - handleFetch, - handleError, - handleValidationError, - init, - reroute, - transport - }; -} - -export { set_assets, set_building, set_env, set_manifest, set_prerendering, set_read_implementation }; diff --git a/frontend/.svelte-kit/non-ambient.d.ts b/frontend/.svelte-kit/non-ambient.d.ts deleted file mode 100644 index 01d8bd8..0000000 --- a/frontend/.svelte-kit/non-ambient.d.ts +++ /dev/null @@ -1,44 +0,0 @@ - -// this file is generated — do not edit it - - -declare module "svelte/elements" { - export interface HTMLAttributes { - 'data-sveltekit-keepfocus'?: true | false | '' | undefined | null; - 'data-sveltekit-noscroll'?: true | false | '' | undefined | null; - 'data-sveltekit-preload-code'?: - | true - | false - | '' - | 'eager' - | 'viewport' - | 'hover' - | 'tap' - | undefined - | null; - 'data-sveltekit-preload-data'?: true | false | '' | 'hover' | 'tap' | undefined | null; - 'data-sveltekit-reload'?: true | false | '' | undefined | null; - 'data-sveltekit-replacestate'?: true | false | '' | undefined | null; - } -} - -export {}; - - -declare module "$app/types" { - type MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string; - - export interface AppTypes { - RouteId(): "/" | "/debug"; - RouteParams(): { - - }; - LayoutParams(): { - "/": Record; - "/debug": Record - }; - Pathname(): "/" | "/debug"; - ResolvedPathname(): `${"" | `/${string}`}${ReturnType}`; - Asset(): "/robots.txt" | string & {}; - } -} \ No newline at end of file diff --git a/frontend/.svelte-kit/tsconfig.json b/frontend/.svelte-kit/tsconfig.json deleted file mode 100644 index da270a8..0000000 --- a/frontend/.svelte-kit/tsconfig.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "compilerOptions": { - "paths": { - "$lib": [ - "../src/lib" - ], - "$lib/*": [ - "../src/lib/*" - ], - "$app/types": [ - "./types/index.d.ts" - ] - }, - "rootDirs": [ - "..", - "./types" - ], - "verbatimModuleSyntax": true, - "isolatedModules": true, - "lib": [ - "esnext", - "DOM", - "DOM.Iterable" - ], - "moduleResolution": "bundler", - "module": "esnext", - "noEmit": true, - "target": "esnext", - "types": [ - "node" - ] - }, - "include": [ - "ambient.d.ts", - "env.d.ts", - "non-ambient.d.ts", - "./types/**/$types.d.ts", - "../svelte.config.js", - "../vite.config.js", - "../vite.config.ts", - "../src/**/*.js", - "../src/**/*.ts", - "../src/**/*.svelte", - "../test/**/*.js", - "../test/**/*.ts", - "../test/**/*.svelte", - "../tests/**/*.js", - "../tests/**/*.ts", - "../tests/**/*.svelte" - ], - "exclude": [ - "../node_modules/**", - "../src/service-worker.js", - "../src/service-worker/**/*.js", - "../src/service-worker.ts", - "../src/service-worker/**/*.ts", - "../src/service-worker.d.ts", - "../src/service-worker/**/*.d.ts" - ] -} \ No newline at end of file diff --git a/frontend/.svelte-kit/types/route_meta_data.json b/frontend/.svelte-kit/types/route_meta_data.json deleted file mode 100644 index 522a20a..0000000 --- a/frontend/.svelte-kit/types/route_meta_data.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "/": [], - "/debug": [ - "src/routes/debug/+page.server.ts" - ] -} \ No newline at end of file diff --git a/frontend/.svelte-kit/types/src/routes/$types.d.ts b/frontend/.svelte-kit/types/src/routes/$types.d.ts deleted file mode 100644 index 35e9cfc..0000000 --- a/frontend/.svelte-kit/types/src/routes/$types.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type * as Kit from '@sveltejs/kit'; - -type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; -type MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string; -type RouteParams = { }; -type RouteId = '/'; -type MaybeWithVoid = {} extends T ? T | void : T; -export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; -type OutputDataShape = MaybeWithVoid> & Partial> & Record> -type EnsureDefined = T extends null | undefined ? {} : T; -type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; -export type Snapshot = Kit.Snapshot; -type PageParentData = EnsureDefined; -type LayoutRouteId = RouteId | "/" | "/debug" | null -type LayoutParams = RouteParams & { } -type LayoutParentData = EnsureDefined<{}>; - -export type PageServerData = null; -export type PageData = Expand; -export type PageProps = { params: RouteParams; data: PageData } -export type LayoutServerData = null; -export type LayoutData = Expand; -export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } \ No newline at end of file diff --git a/frontend/.svelte-kit/types/src/routes/debug/$types.d.ts b/frontend/.svelte-kit/types/src/routes/debug/$types.d.ts deleted file mode 100644 index 605e97c..0000000 --- a/frontend/.svelte-kit/types/src/routes/debug/$types.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type * as Kit from '@sveltejs/kit'; - -type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; -type MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string; -type RouteParams = { }; -type RouteId = '/debug'; -type MaybeWithVoid = {} extends T ? T | void : T; -export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; -type OutputDataShape = MaybeWithVoid> & Partial> & Record> -type EnsureDefined = T extends null | undefined ? {} : T; -type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; -export type Snapshot = Kit.Snapshot; -type PageServerParentData = EnsureDefined; -type PageParentData = EnsureDefined; - -export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; -export type PageServerLoadEvent = Parameters[0]; -export type ActionData = unknown; -export type PageServerData = Expand>>>>>; -export type PageData = Expand & EnsureDefined>; -export type Action | void = Record | void> = Kit.Action -export type Actions | void = Record | void> = Kit.Actions -export type PageProps = { params: RouteParams; data: PageData; form: ActionData } -export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/frontend/src/env.ts b/frontend/src/env.ts index e07cedf..afc4f39 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -3,5 +3,6 @@ import { defineEnvVars } from '@sveltejs/kit/hooks'; export const variables = defineEnvVars({ DEBUG_RECORD_ID: {}, SERVER_IP: {public: true}, - PB_PORT: {public: true} + PB_PORT: {public: true}, + PUBLIC_PB_URL: {public: true} }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..da5c37f --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,16 @@ +import type { Handle } from '@sveltejs/kit'; + +const COOKIE_NAME = 'session'; + +export const handle: Handle = async ({ event, resolve }) => { + const raw = event.cookies.get(COOKIE_NAME); + if (raw) { + try { + event.locals.session = JSON.parse(raw); + } catch { + event.cookies.delete(COOKIE_NAME, { path: '/' }); + } + } + + return resolve(event); +}; diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts new file mode 100644 index 0000000..bcda61d --- /dev/null +++ b/frontend/src/lib/client/api.ts @@ -0,0 +1,39 @@ +import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; + +const BASE_URL = typeof window === 'undefined' + ? `http://${SERVER_IP}:${PROXY_PORT}` + : ''; + +async function memberFetch( + method: string, + path: string, + token: string, + famId: string, + body?: unknown, +): Promise { + const headers: Record = { + 'x-device-token': token, + 'x-device-famid': famId, + }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); + return data as T; +} + +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); + }, +}; diff --git a/frontend/src/lib/components/Accordion.svelte b/frontend/src/lib/components/Accordion.svelte new file mode 100644 index 0000000..8e3e054 --- /dev/null +++ b/frontend/src/lib/components/Accordion.svelte @@ -0,0 +1,36 @@ + + + + {#each items as item, i} + + openIndex = openIndex === i ? null : i}> + {item.title} + {openIndex === i ? '▾' : '▸'} + + {#if openIndex === i} + + {@render item.content()} + + {/if} + + {/each} + + + diff --git a/frontend/src/lib/components/Button.svelte b/frontend/src/lib/components/Button.svelte new file mode 100644 index 0000000..42c9fa4 --- /dev/null +++ b/frontend/src/lib/components/Button.svelte @@ -0,0 +1,49 @@ + + +{#if href} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} + + diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte new file mode 100644 index 0000000..c396fc1 --- /dev/null +++ b/frontend/src/lib/components/Card.svelte @@ -0,0 +1,31 @@ + + + + {#if title} + + {title} + + {/if} + + {@render children?.()} + + + + diff --git a/frontend/src/lib/components/CardGrid.svelte b/frontend/src/lib/components/CardGrid.svelte new file mode 100644 index 0000000..429de91 --- /dev/null +++ b/frontend/src/lib/components/CardGrid.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + + + diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte new file mode 100644 index 0000000..af932e7 --- /dev/null +++ b/frontend/src/lib/components/Footer.svelte @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..669dd96 --- /dev/null +++ b/frontend/src/lib/components/Sidebar.svelte @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/lib/components/TopNav.svelte b/frontend/src/lib/components/TopNav.svelte new file mode 100644 index 0000000..1081032 --- /dev/null +++ b/frontend/src/lib/components/TopNav.svelte @@ -0,0 +1,30 @@ + + + + + {#if announcement}{announcement}{/if} + + + {@render children?.()} + + + + diff --git a/frontend/src/lib/components/ViewHeader.svelte b/frontend/src/lib/components/ViewHeader.svelte new file mode 100644 index 0000000..b1dadd1 --- /dev/null +++ b/frontend/src/lib/components/ViewHeader.svelte @@ -0,0 +1,71 @@ + + + + + {title} + {#if subtitle}{subtitle}{/if} + + + + {#if tabs} + + {#each tabs.items as tab} + tabs.onchange(tab.value)} + >{tab.label} + {/each} + + {/if} + + {#if weeknav} + + ‹ + {weeknav.current} + › + + {/if} + + {#if sort} + sort.onchange(e.currentTarget.value)}> + {#each sort.options as opt} + {opt.label} + {/each} + + {/if} + + + + diff --git a/frontend/src/lib/components/icons.ts b/frontend/src/lib/components/icons.ts new file mode 100644 index 0000000..70f4fd0 --- /dev/null +++ b/frontend/src/lib/components/icons.ts @@ -0,0 +1,11 @@ +export const dashboardIcon = '' +export const choresIcon = '' +export const rewardsIcon = '' +export const bonusesIcon = '' +export const homeIcon = '' +export const settingsIcon = '' +export const logoutIcon = '' +export const prefsIcon = '' +export const chevronLeft = '' +export const chevronRight = '' +export const bellIcon = '' diff --git a/frontend/src/lib/components/index.ts b/frontend/src/lib/components/index.ts new file mode 100644 index 0000000..7f53072 --- /dev/null +++ b/frontend/src/lib/components/index.ts @@ -0,0 +1,8 @@ +export { default as Sidebar } from './Sidebar.svelte'; +export { default as TopNav } from './TopNav.svelte'; +export { default as Footer } from './Footer.svelte'; +export { default as ViewHeader } from './ViewHeader.svelte'; +export { default as Card } from './Card.svelte'; +export { default as CardGrid } from './CardGrid.svelte'; +export { default as Button } from './Button.svelte'; +export { default as Accordion } from './Accordion.svelte'; diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts new file mode 100644 index 0000000..efe015a --- /dev/null +++ b/frontend/src/lib/crypto.ts @@ -0,0 +1,9 @@ +export async function sha256(input: string): Promise { + const hash = await globalThis.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(input), + ); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index d7046b4..0599ee5 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -1,5 +1,14 @@ import PocketBase from 'pocketbase'; -import { SERVER_IP, PB_PORT } from '$app/env/public'; +import { PUBLIC_PB_URL } from '$app/env/public'; -const url = `http://${SERVER_IP}:${PB_PORT}`; -export const pb: PocketBase = new PocketBase(url); +export const pb = new PocketBase(PUBLIC_PB_URL); +pb.autoCancellation(false); + +export function initPbFromCookie() { + const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/); + if (match) { + pb.authStore.save(match[1], null); + return true; + } + return false; +} diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts new file mode 100644 index 0000000..087b4e7 --- /dev/null +++ b/frontend/src/lib/server/auth.ts @@ -0,0 +1,87 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export function getSession(event: RequestEvent) { + return event.locals.session; +} + +export function requireAuth(event: RequestEvent) { + const session = getSession(event); + if (!session) { + throw redirect(303, '/login'); + } + return session; +} + +export async function signup(email: string, password: string, famName: string, parentName?: string) { + const res = await fetch(`${HONO_URL}/api/admin/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, famName, parentName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Signup failed'); + return data; +} + +export async function login(email: string, password: string) { + console.log(email); + const res = await fetch(`${HONO_URL}/api/admin/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Login failed'); + return data; +} + +export async function joinMember(inviteCode: string, name: string, deviceToken: string) { + const res = await fetch(`${HONO_URL}/api/members/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode, name, deviceToken }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Join failed'); + return data; +} + +export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) { + event.cookies.set('session', JSON.stringify(session), { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30, + secure: false, + }); +} + +export function setDeviceTokenCookie(event: RequestEvent, token: string) { + event.cookies.set('device_token', token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 365, + secure: false, + }); +} + +export function setPbTokenCookie(event: RequestEvent, token: string) { + event.cookies.set('pb_token', token, { + httpOnly: false, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24, + secure: false, + }); +} + +export function clearSession(event: RequestEvent) { + event.cookies.delete('session', { path: '/' }); + event.cookies.delete('pb_token', { path: '/' }); + event.cookies.delete('device_token', { path: '/' }); +} diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts new file mode 100644 index 0000000..0581410 --- /dev/null +++ b/frontend/src/lib/server/hono.ts @@ -0,0 +1,111 @@ +import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; +import type { RequestEvent } from '@sveltejs/kit'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +function sessionHeaders(event: RequestEvent): Record { + const s = event.locals.session; + if (!s) return {}; + return { + 'x-session-famid': s.famId, + 'x-session-userid': s.userId, + 'Content-Type': 'application/json', + }; +} + +async function request(method: string, path: string, body?: unknown, headers?: Record) { + const res = await fetch(`${HONO_URL}${path}`, { + method, + headers: headers || { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); + 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) { + return request('PATCH', `/api/admin/${famId}/fam`, { payday }, 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 regenInvite(event: RequestEvent, famId: string) { + return request('POST', `/api/admin/${famId}/regen-invite`, 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 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, phase?: string) { + return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, 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 sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) { + return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, 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)); + }, + }, +}; diff --git a/frontend/src/lib/server/pb-admin.ts b/frontend/src/lib/server/pb-admin.ts new file mode 100644 index 0000000..7073243 --- /dev/null +++ b/frontend/src/lib/server/pb-admin.ts @@ -0,0 +1,45 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts'; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +let token: string | null = null; +let tokenExpiry = 0; + +async function ensureToken(): Promise { + if (token && Date.now() < tokenExpiry) return token; + const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`); + token = data.token; + tokenExpiry = Date.now() + 23 * 60 * 60 * 1000; + return token!; +} + +export const pbAdmin = { + async getList(collection: string, filter = '') { + const t = await ensureToken(); + const params = new URLSearchParams(); + if (filter) params.set('filter', filter); + params.set('perPage', '200'); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`); + return data.items || []; + }, + + async getOne(collection: string, id: string) { + const t = await ensureToken(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`); + return data; + }, +}; diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts new file mode 100644 index 0000000..7375988 --- /dev/null +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -0,0 +1,155 @@ +import { pb } from '$lib/pocketbase'; +import type { + Member, ChoreTemplate, AssignedChore, Completion, + WeeklyHistory, Reward, BonusConfig, Fam, +} from '$lib/types'; + +type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards'; + +class FamStore { + fam = $state(null) + members = $state([]) + templates = $state([]) + assigned = $state([]) + completions = $state([]) + history = $state([]) + rewards = $state([]) + bonusConfigs = $state([]) + initialized = $state(false) + famId = $state('') + + private unsubs: (() => void)[] = [] + private destroyed = false + + memberMap(): Map { + return new Map(this.members.map((m) => [m.id, m])) + } + + templateMap(): Map { + return new Map(this.templates.map((t) => [t.id, t])) + } + + bonusConfigMap(): Map { + return new Map(this.bonusConfigs.map((b) => [b.id, b])) + } + + assignedForMember(memberId: string): AssignedChore[] { + return this.assigned.filter((a) => a.memberId === memberId) + } + + completionsForDate(date: string): Completion[] { + return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date) + } + + isCompleted(assignedChoreId: string, date: string): boolean { + return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date) + } + + private initPromise: Promise | null = null + + async init(famId: string) { + if (this.initialized && this.famId === famId) return + + // Wait for any in-flight init to finish first + if (this.initPromise) { + await this.initPromise + if (this.initialized && this.famId === famId) return + } + + this.cleanup() + this.destroyed = false + this.famId = famId + + this.initPromise = (async () => { + try { + const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] = + await Promise.all([ + pb.collection('fams').getOne(famId) as Promise, + pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise, + ]) + this.fam = famRes + this.members = membersRes + this.templates = templatesRes + this.assigned = assignedRes + this.completions = completionsRes + this.bonusConfigs = bonusConfigsRes + this.rewards = rewardsRes + this.initialized = true + } catch (e) { + console.error('FamStore.init failed:', e) + this.initPromise = null + throw e + } + + await this.subscribe() + this.initPromise = null + })() + + return this.initPromise! + } + + private async subscribe() { + const subs: { collection: CollectionName; filter?: string }[] = [ + { collection: 'members', filter: this.famId }, + { collection: 'chore_templates', filter: this.famId }, + { collection: 'assigned_chores', filter: this.famId }, + { collection: 'completions', filter: this.famId }, + { collection: 'bonus_configs', filter: this.famId }, + { collection: 'rewards', filter: this.famId }, + ] + + const promises = subs.map(({ collection, filter }) => { + const filterStr = filter ? `famId = '${filter}'` : '' + return pb.collection(collection).subscribe('*', (data: any) => { + if (this.destroyed) return + this.handleRealtime(collection, data.action, data.record) + }, { filter: filterStr || undefined }).then((unsub) => { + if (this.destroyed) { unsub(); return } + this.unsubs.push(unsub) + }).catch((err: Error) => { + console.error(`[famStore] subscribe failed for ${collection}:`, err) + }) + }) + + await Promise.allSettled(promises) + } + + // Called from form action callbacks for instant UI feedback, + // and from PB subscribe SSE for multi-user realtime. + applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { + const apply = (list: T[]): T[] => { + if (action === 'create') return [record, ...list] + if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)) + if (action === 'delete') return list.filter((x) => x.id !== record.id) + return list + } + + switch (collection) { + case 'members': this.members = apply(this.members); break + case 'chore_templates': this.templates = apply(this.templates); break + case 'assigned_chores': this.assigned = apply(this.assigned); break + case 'completions': this.completions = apply(this.completions); break + case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break + case 'rewards': this.rewards = apply(this.rewards); break + } + } + + private handleRealtime(collection: string, action: string, record: any) { + this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete') + } + + cleanup() { + // TODO - we need to properly unsubscribe from pocketbase + this.destroyed = true + for (const unsub of this.unsubs) unsub() + this.unsubs = [] + this.initialized = false + } +} + +export const famStore = new FamStore() diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..165a45e --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,142 @@ +export type Frequency = 'daily' | 'weekly' +export type RewardType = 'points' | 'money' +export type BonusTarget = 'individual' | 'competitive' | 'collaborative' +export type BonusType = 'threshold' | 'count' | 'manual' +export type BonusOccurrence = 'recurring' | 'once' +export type BonusRewardType = 'points' | 'cash' | 'prize' +export type BonusPeriod = 'weekly' | 'monthly' +export type BonusStatus = 'active' | 'archived' +export type BonusState = 'pending' | 'unclaimed' | 'claimed' + +export interface Fam { + id: string + name: string + slug: string + inviteCode: string + stripeCustomerId?: string + featureFlags: Record + created: string + updated: string +} + +export interface Member { + id: string + famId: string + name: string + color: string + deviceToken: string + deviceTokenHint: string + created: string + updated: string +} + +export interface ChoreTemplate { + id: string + famId: string + name: string + description?: string + defaultFrequency: Frequency + defaultType: RewardType + defaultValue: number + created: string + updated: string +} + +export interface AssignedChore { + id: string + famId: string + memberId: string + templateId: string + frequency: Frequency + type: RewardType + value: number + customName?: string + created: string + updated: string +} + +export interface Completion { + id: string + famId: string + memberId: string + assignedChoreId: string + date: string + completedAt: string +} + +export interface WeeklyHistory { + id: string + famId: string + memberId: string + weekStart: string + pointsEarned: number + moneyEarned: number + choresCompleted: number + bonusEarned: number +} + +export interface BonusConfig { + id: string + famId: string + name: string + description?: string + target: BonusTarget + memberId?: string + type: BonusType + occurrence: BonusOccurrence + rewardType: BonusRewardType + rewardValue: string + criteriaValue?: number + period?: BonusPeriod + status: BonusStatus + phase?: 'template' | 'ready' | 'active' | 'completed' + created: string + updated: string +} + +export interface Reward { + id: string + famId: string + memberId: string + bonusConfigId?: string + label: string + value: number + rewardType: BonusRewardType + claimed: boolean + claimedAt?: string + date: string + created: string + updated: string +} + +export interface BonusProgress { + memberId: string + memberName: string + memberColor: string + current: number + criteriaValue: number + reward: { id: string; claimed: boolean } | null + state: BonusState + achieved: boolean +} + +export interface BonusConfigWithProgress { + config: BonusConfig + progress: BonusProgress[] + periodStart: string + periodEnd: string +} + +export interface Settings { + id: string + famId: string + webhookUrl?: string +} + +export interface Session { + famId: string + userId: string + famSlug: string + memberName?: string + role?: string +} diff --git a/frontend/src/routes/[fam]/+error.svelte b/frontend/src/routes/[fam]/+error.svelte new file mode 100644 index 0000000..3e24f0c --- /dev/null +++ b/frontend/src/routes/[fam]/+error.svelte @@ -0,0 +1,18 @@ + + + + Something went wrong + {error?.message || 'An unexpected error occurred.'} + Try logging out and signing in again. + + + diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts new file mode 100644 index 0000000..093e12b --- /dev/null +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -0,0 +1,4 @@ +export function load(event) { + const session = event.locals.session || null; + return { session, isParent: session !== null, role: session?.role || 'child' }; +} diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte new file mode 100644 index 0000000..260d738 --- /dev/null +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -0,0 +1,69 @@ + + + + + + + {@render children()} + + {#if claimToast} + {claimToast} + {/if} + + + + diff --git a/frontend/src/routes/[fam]/+page.server.ts b/frontend/src/routes/[fam]/+page.server.ts new file mode 100644 index 0000000..edcaadb --- /dev/null +++ b/frontend/src/routes/[fam]/+page.server.ts @@ -0,0 +1,20 @@ +import { hono } from '$lib/server/hono'; + +export async function load(event) { + const session = event.locals.session; + if (!session) return {}; + + const famId = session.famId; + + 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), + ]); + return { members, templates, assigned, summary }; + } catch { + return {}; + } +} diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte new file mode 100644 index 0000000..f957488 --- /dev/null +++ b/frontend/src/routes/[fam]/+page.svelte @@ -0,0 +1,175 @@ + + +🏠 {famSlug} + +{#if summary?.summaries?.length} + This week + + MemberPointsDone + + {#each summary.summaries as s} + + ■ {s.memberName} + {s.pointsEarned} + {s.choresCompleted}/{s.totalChores} + + {/each} + + + + + Chores per day + + + + + {#each summary.summaries as s} + + + {s.memberName} + {s.pointsEarned} pts + {s.choresCompleted}/{s.totalChores} chores + + {/each} + +{/if} + +Members ({members?.length || 0}) + + {#each members || [] as m} + + {m.name} + {m.deviceTokenHint || '—'} + + {/each} + + +{#if templates?.length} + Templates ({templates.length}) + + {#each templates as t} + {t.name} — {t.defaultFrequency} — {t.defaultValue} {t.defaultType} + {/each} + +{/if} + + diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts new file mode 100644 index 0000000..54ecd24 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -0,0 +1,122 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; +import { memberApi } from '$lib/client/api'; +import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export async function load(event) { + const session = event.locals.session; + + // Parent (session auth) → admin overview + if (session) { + const famId = session.famId; + const famSlug = event.params.fam; + const username = event.params.username; + if (session.memberName && session.memberName !== username) { + throw redirect(303, `/${famSlug}/${session.memberName}`); + } + const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = 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), + ]); + return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions }; + } + + // Child (device token) → kanban + const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; + const famSlug = event.params.fam; + const username = event.params.username; + + if (!deviceToken) { + return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + } + + 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 { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + } + + let chores: any = {}; + try { + chores = await memberApi.myChores(deviceToken, data.famId); + } catch {} + + return { + role: 'child', + token: deviceToken, + memberId: data.memberId, + famId: data.famId, + verified: true, + memberName: data.name, + memberColor: data.color, + templates: chores.templates || [], + assigned: chores.assigned || [], + completions: chores.completions || [], + rewards: chores.rewards || [], + bonusConfigs: chores.bonusConfigs || [], + tallies: chores.tallies || {}, + }; + } catch { + return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + } +} + +export const actions = { + claim: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const rewardId = fd.get('id') as string; + const message = fd.get('message') as string; + try { + const record = await hono.admin.claimReward(event, famId, rewardId); + if (message) { + await hono.admin.sendMessage(event, famId, record.memberId, message); + } + return { record }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; + } + }, + + revoke: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const completionId = fd.get('id') as string; + try { + await hono.admin.revokeCompletion(event, famId, completionId); + return { revoked: true, id: completionId }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to revoke' }; + } + }, + + trigger: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const configId = fd.get('configId') as string; + 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); + return { records: result.records || [] }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to trigger' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte new file mode 100644 index 0000000..b83869f --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -0,0 +1,813 @@ + + +{#if role === 'parent'} + + + + {#if toast} + {toast} + {/if} + + + + {#each parentMembers as m} + {@const total = totalChoresFor(m.id)} + {@const todays = todayCompletionsFor(m.id)} + {@const done = todays.length} + {@const pct = total > 0 ? Math.round((done / total) * 100) : 0} + {@const s = memberInSummary(m.id)} + + + + {m.name} + Kanban + + + Points: {s?.pointsEarned ?? 0} + Money: £{(s?.moneyEarned ?? 0).toFixed(2)} + + + Today: + + + + {done}/{total} + + {#if done > 0} + + {#each todays as c} + + ✅ {choreNameFor(c.assignedChoreId)} + { return async (args) => handleResult(args, (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); }); }} class="revoke-form"> + + ↩ + + + {/each} + + {/if} + + {/each} + + + + {#if manualConfigs().length === 0} + No manual bonus configs. + {:else} + {#each manualConfigs() as bc} + {@const val = bc.rewardType === 'cash' ? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` : bc.rewardType === 'points' ? `${bc.rewardValue} pts` : bc.rewardValue} + {@const targeted = bc.memberId ? parentMembers.find((m: any) => m.id === bc.memberId) : null} + + + {bc.name} + {val} + + {#if targeted} + { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}> + + + + ■ {targeted.name} + Award + + + {:else} + { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}> + + + + Select member + {#each parentMembers as m} + {m.name} + {/each} + + Award + + + {/if} + + {/each} + {/if} + + + + {#if claimableRewards().length === 0} + No outstanding claims + {:else} + {#each parentMembers as m} + {@const mc = memberClaimable(m.id)} + {#if mc.length > 0} + + {m.name} + {#each mc as r} + { return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}> + + + + {r.label} + + {rewardLabel(r)} + Claim + + + + {/each} + + Response: + + {#each responseTags as tag} + selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag} + >{tag} + {/each} + + { if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; }} + /> + + + {/if} + {/each} + {/if} + + +{:else} + + + + + + + + { e.stopPropagation(); showColorPicker = !showColorPicker }} + onkeydown={e => e.key === 'Enter' && (showColorPicker = !showColorPicker)}> + {#if editingName} + { + if (e.key === 'Enter') { + editingName = false; + if (nameInput && nameInput !== memberName) { + const old = memberName; + memberName = nameInput; + try { + const res = await fetch('/api/members/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId }, + body: JSON.stringify({ name: nameInput }), + }); + if (!res.ok) memberName = old; + } catch { memberName = old; } + } + } else if (e.key === 'Escape') { + editingName = false; + nameInput = memberName; + } + }} + onblur={() => { editingName = false; nameInput = memberName; }} + autofocus /> + {:else} + { nameInput = memberName; editingName = true; }} + onkeydown={e => e.key === 'Enter' && (nameInput = memberName, editingName = true)}> + {memberName || username} + + {/if} + + + {#if showColorPicker} + + showColorPicker = false}> + e.stopPropagation()}> + Pick a color: + + {#each ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899','#78716c'] as color} + { + const old = memberColor; + memberColor = color; + showColorPicker = false; + try { + const res = await fetch('/api/members/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId }, + body: JSON.stringify({ color }), + }); + if (!res.ok) memberColor = old; + } catch { memberColor = old; } + }}> + {/each} + + + + {/if} + + {#if loading} + Loading... + {:else if error} + {error} + {:else} + + + + + Bonus Notices + {bonusNotices.length} + {#if bonusNotices.length > 0} + +{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts + {/if} + + + Total Cash + £{(totalCash / 100).toFixed(2)} + + + Pending Cash + £{(pendingCash / 100).toFixed(2)} + + + Total Points + {totalPoints} + + + + + {#if weeklyTotal > 0} + {@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)} + + + This Week + {weeklyDone}/{weeklyTotal} done + + + + + + {/if} + + + {#if bonusProgresses.length > 0} + + {#each bonusProgresses as bp} + {@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0} + {@const hasReward = bp.periodStart ? rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id && r.date >= bp.periodStart && r.date <= bp.periodEnd) : rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id)} + {@const typeClass = bp.config.type === 'threshold' ? 'type-threshold' : bp.config.type === 'count' ? 'type-count' : 'type-manual'} + + + {bp.config.name} + {bp.config.type} + + + {#if bp.criteria > 0} + + + + {bp.current}/{bp.criteria} {bp.config.type === 'threshold' ? 'pts' : 'chores'} + {/if} + + {#if hasReward} + Reward earned 🎉 + {/if} + + {/each} + + {/if} + + + + + Daily Pending ({dailyPending.length}) + {#if dailyPending.length === 0} + All done! + {:else} + {#each dailyPending as chore} + toggle(chore)} disabled={!!togglingIds}> + ⬜ + {choreName(chore)} + {chore.value} {chore.type} + + {/each} + {/if} + + + + Weekly Pending ({weeklyPending.length}) + {#if weeklyPending.length === 0} + All done! + {:else} + {#each weeklyPending as chore} + toggle(chore)} disabled={!!togglingIds}> + ⬜ + {choreName(chore)} + {chore.value} {chore.type} + + {/each} + {/if} + + + + Completed Today ({completedToday.length}) + {#if completedToday.length === 0} + Nothing yet + {:else} + {#each completedToday as c} + {@const chore = assigned.find(a => a.id === c.assignedChoreId)} + {#if chore} + toggle(chore)} disabled={!!togglingIds}> + ✅ + {choreName(chore)} + {chore.value} {chore.type} + + {/if} + {/each} + {/if} + + + + Claimable ({claimableRewardsChild.length}) + {#if claimableRewardsChild.length === 0} + Nothing to claim + {:else} + {#each claimableRewardsChild as r} + + + {r.label} + {rewardLabelChild(r)} + + + {r.rewardType} + { try { await memberApi.claimReward(deviceToken, famId, r.id); requestedRewards = [...requestedRewards, r.id]; } catch (e) { claimError = e instanceof Error ? e.message : 'Claim failed'; } }} disabled={requestedRewards.includes(r.id)}> + {requestedRewards.includes(r.id) ? 'Requested' : 'Request'} + + + + {/each} + {/if} + + + + {/if} + + +{/if} + + diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts new file mode 100644 index 0000000..091b401 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts @@ -0,0 +1,186 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; + +export async function load(event) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [configs, members, progress, rewards] = await Promise.all([ + hono.admin.bonusConfigs(event, famId), + hono.admin.list(event, 'members', famId), + hono.admin.bonusConfigProgress(event, famId), + hono.admin.rewards(event, famId), + ]); + return { configs, members, progress, rewards }; +} + +export const actions = { + createConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const phase = fd.get('phase') as string; + const data: Record = { + name: fd.get('name'), + target: fd.get('target'), + type: fd.get('type'), + occurrence: fd.get('occurrence'), + rewardType: fd.get('rewardType'), + rewardValue: fd.get('rewardValue'), + phase: phase || 'ready', + }; + const criteriaValue = fd.get('criteriaValue'); + if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; + const period = fd.get('period'); + if (period !== null) data.period = period; + const description = fd.get('description'); + if (description) data.description = description; + const memberId = fd.get('memberId'); + if (memberId) data.memberId = memberId; + try { + const record = await hono.admin.create(event, 'bonus-configs', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' }); + } + }, + + updateConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const data: Record = {}; + const name = fd.get('name'); + if (name) data.name = name; + const target = fd.get('target'); + if (target) data.target = target; + const type = fd.get('type'); + if (type) data.type = type; + const occurrence = fd.get('occurrence'); + if (occurrence) data.occurrence = occurrence; + const rewardType = fd.get('rewardType'); + if (rewardType) data.rewardType = rewardType; + const rewardValue = fd.get('rewardValue'); + if (rewardValue) data.rewardValue = rewardValue; + const criteriaValue = fd.get('criteriaValue'); + if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; + const period = fd.get('period'); + if (period !== null) data.period = period; + const description = fd.get('description'); + if (description) data.description = description; + 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); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' }); + } + }, + + deleteConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + await hono.admin.remove(event, 'bonus-configs', famId, id); + return { deleted: true }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' }); + } + }, + + createFromTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const templateId = fd.get('templateId') as string; + try { + const templates = await hono.admin.bonusConfigs(event, famId); + const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId); + if (!tmpl) return fail(400, { error: 'Template not found' }); + const data: Record = { + name: tmpl.name, + description: tmpl.description || '', + target: tmpl.target, + type: tmpl.type, + occurrence: tmpl.occurrence, + rewardType: tmpl.rewardType, + rewardValue: tmpl.rewardValue, + criteriaValue: tmpl.criteriaValue || 0, + period: tmpl.period || '', + memberId: tmpl.memberId || '', + phase: 'ready', + }; + const record = await hono.admin.create(event, 'bonus-configs', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' }); + } + }, + + assignConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const target = fd.get('target') as string; + const memberId = fd.get('memberId') as string; + try { + const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null }); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' }); + } + }, + + completeConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const phase = fd.get('phase') as string; + try { + const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed'); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' }); + } + }, + + destroyConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + await hono.admin.destroyBonusConfig(event, famId, id); + return { destroyed: true }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' }); + } + }, + + claimReward: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const rewardId = fd.get('id') as string; + try { + await hono.admin.claimReward(event, famId, rewardId); + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; + } + }, + + evaluate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + try { + await hono.admin.evaluateBonusConfig(event, famId); + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to evaluate' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte new file mode 100644 index 0000000..1fd9cec --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -0,0 +1,1071 @@ + + + + + + + + Evaluate All + + + + + + Templates + + + {#if readyConfigs.length} + {#each readyConfigs as cfg} + + + {cfg.name} + + + × + + + + {cfg.target} + {cfg.type} + + {formatReward(cfg)} + openEdit(cfg)} variant="secondary" size="sm">Edit + + + + Individual + Competitive + Collaborative + + + All + {#each members as m} + {m.name} + {/each} + + Assign + + + {/each} + {/if} + + (showCreateModal = true)}>+ New Bonus + + {#each templateConfigs as cfg} + handleDragStart(e, cfg)} + > + + {cfg.name} + + + × + + + + {cfg.target} + {cfg.type} + {cfg.occurrence} + {#if cfg.period}{cfg.period}{/if} + + {formatReward(cfg)} + openEdit(cfg)} variant="secondary" size="sm">Edit + + {/each} + + + + + Member + {#each activeConfigs.filter((c) => c.target === 'individual') as cfg} + + + {cfg.name} + + {cfg.target} + {cfg.type} + {cfg.occurrence} + + + {#each members as m} + {#if !cfg.memberId || cfg.memberId === m.id} + {@const p = progressByConfigId.get(cfg.id)?.progress.find((pr: BonusProgress) => pr.memberId === m.id)} + {#if p} + + {p.memberName} + + + + {p.state} + + {/if} + {/if} + {/each} + + openEdit(cfg)} variant="secondary" size="sm">Edit + + + {/each} + {#if activeConfigs.filter((c) => c.target === 'individual').length === 0} + Drag a template here + {/if} + + + + + Competition + {#each activeConfigs.filter((c) => c.target === 'competitive') as cfg} + {@const prog = progressByConfigId.get(cfg.id)} + + + {cfg.name} + + {cfg.target} + {cfg.type} + {cfg.occurrence} + + + {#if prog} + + {#each [...prog.progress].sort((a, b) => b.current - a.current) as p, i} + 0}> + #{i + 1} + {p.memberName} + + + + {p.state} + + {/each} + + {/if} + + openEdit(cfg)} variant="secondary" size="sm">Edit + + + {/each} + {#if activeConfigs.filter((c) => c.target === 'competitive').length === 0} + Drag a template here + {/if} + + + + + Collaboration + {#each activeConfigs.filter((c) => c.target === 'collaborative') as cfg} + {@const prog = progressByConfigId.get(cfg.id)} + + + {cfg.name} + + {cfg.target} + {cfg.type} + {cfg.occurrence} + + + {#if prog} + {@const team = prog.progress[0]} + {#if team} + + {team.memberName} + + + + {team.current}/{team.criteriaValue} + {cfg.type === 'threshold' ? 'pts' : 'chores'} + {team.state} + + {/if} + {/if} + + openEdit(cfg)} variant="secondary" size="sm">Edit + + + {/each} + {#if activeConfigs.filter((c) => c.target === 'collaborative').length === 0} + Drag a template here + {/if} + + + + +{#if completedConfigs.length} + + Completed + + {#each completedConfigs as cfg} + + + {cfg.name} + + {cfg.target} + {cfg.type} + + + {formatReward(cfg)} + + openEdit(cfg)} variant="secondary" size="sm">Edit + + + + Reactivate + + + + × + + + + {/each} + + +{/if} + + +{#if showCreateModal} + (showCreateModal = false)} role="presentation"> + e.stopPropagation()} role="dialog"> + New Bonus + + + Name + Description + Target + + Individual + Competitive + Collaborative + + + {#if createVals.target === 'individual'} + Member + + All (everyone) + {#each members as m} + {m.name} + {/each} + + + {/if} + Type + + Threshold (points) + Count (completions) + Manual (admin awards) + + + {#if showCriteriaForCreate} + Criteria Value + + {createVals.type === 'threshold' + ? 'Points needed' + : 'Chore completions needed'} + + {/if} + Occurrence + + Recurring + Once + + + {#if showPeriodForCreate} + Period + + Anytime + Weekly + Monthly + + + {/if} + Reward Type + + Points + Cash + Prize + + + Reward Value + + {createVals.rewardType === 'cash' + ? 'Amount in pence (e.g. 200 = £2)' + : createVals.rewardType === 'points' + ? 'Points to award' + : 'Prize description'} + + + (showCreateModal = false)}>Cancel + Create + + + + +{/if} + + +{#if showEditModal && editingConfig} + { + showEditModal = false; + editingConfig = null; + }} + role="presentation" + > + e.stopPropagation()} role="dialog"> + Edit: {editingConfig.name} + + + + Name + Description + Target + + Individual + Competitive + Collaborative + + + {#if editVals.target === 'individual'} + Member + + All (everyone) + {#each members as m} + {m.name} + {/each} + + + {/if} + Type + + Threshold (points) + Count (completions) + Manual (admin awards) + + + {#if showCriteria} + Criteria Value + + {editVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'} + + {/if} + Occurrence + + Recurring + Once + + + {#if showPeriod} + Period + + Anytime + Weekly + Monthly + + + {/if} + Reward Type + + Points + Cash + Prize + + + Reward Value + + {editVals.rewardType === 'cash' + ? 'Amount in pence' + : editVals.rewardType === 'points' + ? 'Points to award' + : 'Prize description'} + + + { + showEditModal = false; + editingConfig = null; + }}>Cancel + Save + {#if editingConfig.phase === 'template'} + Create Instance + {:else if editingConfig.phase === 'ready' || editingConfig.phase === 'active'} + + Mark Complete + {:else if editingConfig.phase === 'completed'} + + Reactivate + {/if} + + + + +{/if} + + + + diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts new file mode 100644 index 0000000..89490f5 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts @@ -0,0 +1,140 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; + +export async function load(event) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [templates, members, assigned] = await Promise.all([ + hono.admin.list(event, 'chore-templates', famId), + hono.admin.list(event, 'members', famId), + hono.admin.list(event, 'assigned-chores', famId), + ]); + return { templates, members, assigned }; +} + +export const actions = { + createTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const data = { + name: fd.get('name'), + defaultFrequency: fd.get('defaultFrequency'), + defaultType: fd.get('defaultType'), + defaultValue: parseFloat(fd.get('defaultValue') as string) || 0, + }; + if (!data.name || !data.defaultFrequency || !data.defaultType) { + return fail(400, { error: 'Name, frequency, and type are required' }); + } + try { + const record = await hono.admin.create(event, 'chore-templates', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' }); + } + }, + + updateTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const defaultFrequency = fd.get('defaultFrequency') as string; + const defaultType = fd.get('defaultType') as string; + const defaultValue = parseFloat(fd.get('defaultValue') as string) || 0; + const data = { + name: fd.get('name'), + defaultFrequency, + defaultType, + defaultValue, + }; + try { + const record = await hono.admin.update(event, 'chore-templates', famId, id, data); + const allAssigned = await hono.admin.list(event, 'assigned-chores', famId); + if (Array.isArray(allAssigned)) { + const toUpdate = allAssigned.filter((a: any) => a.templateId === id); + await Promise.all(toUpdate.map((a: any) => + hono.admin.update(event, 'assigned-chores', famId, a.id, { + frequency: defaultFrequency, + type: defaultType, + value: defaultValue, + }) + )); + } + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' }); + } + }, + + deleteTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + const record = await hono.admin.remove(event, 'chore-templates', famId, id); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' }); + } + }, + + assignChore: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const data = { + memberId: fd.get('memberId'), + templateId: fd.get('templateId'), + frequency: fd.get('frequency'), + type: fd.get('type'), + value: parseFloat(fd.get('value') as string) || 0, + customName: fd.get('customName') || undefined, + }; + if (!data.memberId || !data.templateId || !data.frequency || !data.type) { + return fail(400, { error: 'Member, template, frequency, and type are required' }); + } + try { + const record = await hono.admin.create(event, 'assigned-chores', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' }); + } + }, + + updateAssignedChore: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const data: Record = {}; + const frequency = fd.get('frequency'); + const type = fd.get('type'); + const value = fd.get('value'); + const customName = fd.get('customName'); + if (frequency) data.frequency = frequency; + if (type) data.type = type; + if (value) data.value = parseFloat(value as string) || 0; + data.customName = (customName as string) || undefined; + try { + const record = await hono.admin.update(event, 'assigned-chores', famId, id, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' }); + } + }, + + unassignChore: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + const record = await hono.admin.remove(event, 'assigned-chores', famId, id); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' }); + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte new file mode 100644 index 0000000..f1af52c --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -0,0 +1,402 @@ + + + + +{#if form?.error} + {form.error} +{/if} + + + + + showCreateModal = true}>+ New Chore Template + + + + Templates + {#each templates as t} + handleDragStart(e, t.id)} + > + openEditTemplate(t)} role="button" tabindex="0"> + {t.name} + {t.defaultFrequency} + {t.defaultType} + + + {t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue} + + + openEditTemplate(t)} class="edit-btn" title="Edit template">✎ + { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } }; }}> + + × + + + + {/each} + {#if templates.length === 0} + No templates yet + {/if} + + + {#each members as m} + handleDrop(e, m.id)} + > + + + {m.name} + + {#each assignedForMember(m.id) as a} + {@const tName = templateName(a.templateId)} + openEdit(a)} role="button" tabindex="0"> + + {a.customName || tName} + {a.frequency} + {a.type} + + {a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value} + e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}> + + × + + + {/each} + {#if assignedForMember(m.id).length === 0} + Drop a chore here + {/if} + + {/each} + + + + + +{#if showCreateModal} + showCreateModal = false} role="presentation"> + e.stopPropagation()} role="dialog"> + New Chore Template + + + Name + + + + Frequency + + Daily + Weekly + + + + Type + + Points + Money + + + + Default Value + + + + showCreateModal = false}>Cancel + Create + + + + +{/if} + + +{#if showEditTemplateModal && editingTemplate} + + e.stopPropagation()} role="dialog"> + Edit Template: {editingTemplate.name} + Changes will also update all existing assigned chores using this template. + + + + Name + + + + Frequency + + Daily + Weekly + + + + Type + + Points + Money + + + + Default Value + + + + Cancel + Save + + + + +{/if} + + +{#if showEditModal && editingAssignment} + + e.stopPropagation()} role="dialog"> + Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)} + + + + Custom Name + + + + Frequency + + Daily + Weekly + + + + Type + + Points + Money + + + + Value + + + + Cancel + Save + + + + +{/if} + + diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts new file mode 100644 index 0000000..71ec924 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts @@ -0,0 +1,99 @@ +import { redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; +import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export async function load(event) { + const session = event.locals.session; + const famSlug = event.params.fam; + const username = event.params.username; + + // Parent (session auth) — profile lives in fam_admins + if (session) { + const famId = session.famId; + if (session.memberName && session.memberName !== username) { + throw redirect(303, `/${famSlug}/${session.memberName}/preferences`); + } + try { + const me = await hono.admin.getProfile(event, famId); + return { + verified: true, token: '', memberId: me.id, famId, + memberName: me.name, memberColor: me.color, email: me.email || '', + session: true, + }; + } catch { + return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; + } + } + + // 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: '' }; + } +} + +export const actions = { + update: async (event) => { + const session = event.locals.session; + 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; + 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); + 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' }; + + 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 }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Update failed' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.svelte b/frontend/src/routes/[fam]/[username]/preferences/+page.svelte new file mode 100644 index 0000000..65d3b68 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.svelte @@ -0,0 +1,99 @@ + + + + +{#if !data.verified} + + + + Not authenticated. Use the link from your invite. + + + +{:else} + + + + + + Display Name + + + + + + + Color + + + {#each colors as c} + memberColor = c} + > + {/each} + + + + + + + Email + + {#if data.session} + + {:else} + + {/if} + + + {#if form?.error} + {form.error} + {/if} + {#if form?.success} + Saved! + {/if} + + Save + + + + + + To change your device or sign in on a new device, use your invite link. + + + Contact your family admin to change your token or reset your account. + + + +{/if} diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts new file mode 100644 index 0000000..9939d46 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; + +export async function load(event) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [rewards, members] = await Promise.all([ + hono.admin.rewards(event, famId), + hono.admin.list(event, 'members', famId), + ]); + return { rewards, members }; +} + +export const actions = { + claim: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const rewardId = fd.get('id') as string; + try { + const record = await hono.admin.claimReward(event, famId, rewardId); + return { record }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte new file mode 100644 index 0000000..147373d --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -0,0 +1,137 @@ + + + + +{#if toast} + {toast} +{/if} + + + + {#if rewards.length === 0} + No rewards yet. Points earned will automatically create bonus rewards when thresholds are met. + {:else} + + + + Date + Member + Description + Amount + Status + + + + + {#each sorted as r} + {@const outstanding = isOutstanding(r)} + + {r.date?.slice(0, 10) || '—'} + + + {memberName(r.memberId)} + + {r.label} + {rewardAmount(r)} + + + {rewardStatus(r)} + + + + {#if outstanding} + { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}> + + Claim + + {/if} + + + {/each} + + + + + + Outstanding cash + £{(totalOutstanding / 100).toFixed(2)} + + + + + + {/if} + + + + diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts new file mode 100644 index 0000000..bfdff35 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts @@ -0,0 +1,57 @@ +import { redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; +import type { RequestEvent } from '@sveltejs/kit'; + +export async function load(event: RequestEvent) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [members, fam] = await Promise.all([ + hono.admin.list(event, 'members', famId), + hono.admin.fam(event, famId), + ]); + return { members, fam }; +} + +export const actions = { + regenInvite: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode }; + }, + + addMember: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + if (!name) return { error: 'Name required' }; + await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' }); + }, + + renameFam: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + if (!name) return { error: 'Name required' }; + return await hono.admin.renameFam(event, famId, name); + }, + + deleteMember: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + if (!id) return { error: 'Member ID required' }; + await hono.admin.remove(event, 'members', famId, id); + }, + + updatePayday: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const payday = parseInt(fd.get('payday') as string, 10); + if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' }; + return await hono.admin.updatePayday(event, famId, payday); + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte new file mode 100644 index 0000000..16670bc --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte @@ -0,0 +1,171 @@ + + + + + + + + + Rename + + + + + + + Add + + + + {#each members as m} + + + {m.name} + {m.deviceToken ? '' : ' (pending join)'} + Kanban + + + confirm('Remove {m.name}?')}>Remove + + + {/each} + + + + + + + Sunday + Monday + Tuesday + Wednesday + Thursday + Friday + Saturday + + Set payday + + The week starts on this day for tracking weekly chores and bonus cycles. + + + + {fam?.inviteCode || '...'} + + + Member: + + — General link — + {#each members as m} + {m.name} + {/each} + + + Re-joining a member will disconnect their old device. + + {selectedInviteUrl} + + + copy(selectedInviteUrl)} size="sm"> + {copied ? 'Copied!' : 'Copy link'} + + + { showQR = !showQR; if (!showQR) { qrDataUrl = ''; } else { await generateQR(selectedInviteUrl); } }} size="sm"> + {showQR ? 'Hide QR' : 'Show QR'} + + + + Renew code + + + + {#if showQR && qrDataUrl} + + {/if} + + + + Send an email invitation for another parent to join as an admin. + + + Send invite + + They will set up their own password on first login. + + + + + Download CSV (coming soon) + Delete Family (coming soon) + + + + + diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..8d870e4 --- /dev/null +++ b/frontend/src/routes/admin/+page.server.ts @@ -0,0 +1,23 @@ +import { pbAdmin } from '$lib/server/pb-admin'; + +export async function load() { + const fams = await pbAdmin.getList('fams'); + const famsWithStats = await Promise.all(fams.map(async (fam: any) => { + const [members, rewards] = await Promise.all([ + pbAdmin.getList('members', `famId = '${fam.id}'`), + pbAdmin.getList('rewards', `famId = '${fam.id}'`), + ]); + return { + id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode, + memberCount: members.length, + claimedRewards: (rewards as any[]).filter((r: any) => r.claimed).length, + totalRewards: rewards.length, + }; + })); + + const totalFams = fams.length; + const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0); + const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0); + + return { fams: famsWithStats, totalFams, totalMembers, totalRewards }; +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..cc08a88 --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,37 @@ + + +Super Admin + + + {data.totalFams} Families + {data.totalMembers} Members + {data.totalRewards} Rewards + + +Families + + NameSlugMembersRewardsInvite Code + + {#each data.fams as fam} + + {fam.name} + {fam.slug} + {fam.memberCount} + {fam.claimedRewards}/{fam.totalRewards} + {fam.inviteCode} + + {/each} + + + + diff --git a/frontend/src/routes/join/[code]/+page.server.ts b/frontend/src/routes/join/[code]/+page.server.ts new file mode 100644 index 0000000..bfd9c19 --- /dev/null +++ b/frontend/src/routes/join/[code]/+page.server.ts @@ -0,0 +1,24 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { joinMember, setDeviceTokenCookie } from '$lib/server/auth'; + +export const actions = { + default: async (event) => { + const code = event.params.code; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + + if (!name) { + return fail(400, { error: 'Name is required' }); + } + + const deviceToken = crypto.randomUUID(); + + try { + const result = await joinMember(code, name, deviceToken); + setDeviceTokenCookie(event, deviceToken); + throw redirect(303, `/${result.famSlug}/${result.name}`); + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' }); + } + }, +}; diff --git a/frontend/src/routes/join/[code]/+page.svelte b/frontend/src/routes/join/[code]/+page.svelte new file mode 100644 index 0000000..1fd3601 --- /dev/null +++ b/frontend/src/routes/join/[code]/+page.svelte @@ -0,0 +1,29 @@ + + +Join family + +Enter your name to join. It must match a member slot created by your admin. + + + {#if form?.error} + {form.error} + {/if} + + + Your name + + + + Join + + + diff --git a/frontend/src/routes/join/[code]/[member]/+page.server.ts b/frontend/src/routes/join/[code]/[member]/+page.server.ts new file mode 100644 index 0000000..9b1b6b6 --- /dev/null +++ b/frontend/src/routes/join/[code]/[member]/+page.server.ts @@ -0,0 +1,24 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { setDeviceTokenCookie } from '$lib/server/auth'; +import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export const actions = { + default: async (event) => { + const code = event.params.code; + const name = event.params.member; + + const res = await fetch(`${HONO_URL}/api/members/direct-join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode: code, name }), + }); + const data = await res.json(); + if (!res.ok) return fail(400, { error: data.error || 'Join failed' }); + + setDeviceTokenCookie(event, data.deviceToken); + + throw redirect(303, `/${data.famSlug}/${data.name}`); + }, +}; diff --git a/frontend/src/routes/join/[code]/[member]/+page.svelte b/frontend/src/routes/join/[code]/[member]/+page.svelte new file mode 100644 index 0000000..0cb1f4c --- /dev/null +++ b/frontend/src/routes/join/[code]/[member]/+page.svelte @@ -0,0 +1,24 @@ + + +Join family + +{#if form?.error} + {form.error} +{:else} + Click below to join as {memberName} +{/if} + + + Join + + + diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 0000000..e4d8852 --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,33 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth'; + +export const actions = { + default: async (event) => { + + const fd = await event.request.formData(); + const email = fd.get('email') as string; + const password = fd.get('password') as string; + + if (!email || !password) { + return fail(400, { error: 'Email and password required', email }); + } + + let result: any; + try { + result = await login(email, password); + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email }); + } + + setSessionCookie(event, { + famId: result.famId, + userId: result.userId, + famSlug: result.famSlug, + memberName: result.memberName, + role: result.role, + }); + setPbTokenCookie(event, result.token); + + throw redirect(303, `/${result.famSlug}/${result.memberName}`); + }, +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..d9ec0b8 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,35 @@ + + +Log in + + + {#if form?.error} + {form.error} + {/if} + + + Email + + + + + Password + + + + Log in + + +Don't have a family yet? Create one + + diff --git a/frontend/src/routes/logout/+page.server.ts b/frontend/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..1a179b9 --- /dev/null +++ b/frontend/src/routes/logout/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +import { clearSession } from '$lib/server/auth'; + +export function load(event) { + clearSession(event); + throw redirect(303, '/'); +} + +export const actions = { + default: (event) => { + clearSession(event); + throw redirect(303, '/'); + }, +}; diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts new file mode 100644 index 0000000..830d047 --- /dev/null +++ b/frontend/src/routes/signup/+page.server.ts @@ -0,0 +1,35 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth'; + +export const actions = { + default: async (event) => { + const fd = await event.request.formData(); + const email = fd.get('email') as string; + const password = fd.get('password') as string; + const famName = fd.get('famName') as string; + const parentName = fd.get('parentName') as string; + + if (!email || !password || !famName) { + return fail(400, { error: 'All fields required', email, famName }); + } + if (password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters', email, famName }); + } + + try { + const result = await signup(email, password, famName, parentName || email.split('@')[0]); + setSessionCookie(event, { + famId: result.famId, + userId: result.userId, + famSlug: result.famSlug, + memberName: result.memberName, + role: result.role, + }); + setPbTokenCookie(event, result.token); + throw redirect(303, `/${result.famSlug}/${result.memberName}`); + } catch (e) { + if (e instanceof redirect) throw e; + return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName }); + } + }, +}; diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte new file mode 100644 index 0000000..1d1e8b4 --- /dev/null +++ b/frontend/src/routes/signup/+page.svelte @@ -0,0 +1,47 @@ + + +Create your family + + + {#if form?.error} + {form.error} + {/if} + + + Family name + + + + + Your name (parent) + + + + + Email + + + + + Password + + + + Create family + + +Already have an account? Log in + + diff --git a/proxy/.gitignore b/proxy/.gitignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/proxy/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/proxy/dist/index.d.ts b/proxy/dist/index.d.ts deleted file mode 100644 index e26a57a..0000000 --- a/proxy/dist/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/proxy/dist/index.d.ts.map b/proxy/dist/index.d.ts.map deleted file mode 100644 index 535b86d..0000000 --- a/proxy/dist/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/proxy/dist/index.js b/proxy/dist/index.js deleted file mode 100644 index cbecddd..0000000 --- a/proxy/dist/index.js +++ /dev/null @@ -1,35 +0,0 @@ -import { serve } from '@hono/node-server'; -import { Hono } from 'hono'; -const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8090'; -const PB_EMAIL = process.env.PB_SUPERUSER_EMAIL || ''; -const PB_PASSWORD = process.env.PB_SUPERUSER_PASSWORD || ''; -const DEBUG_RECORD_ID = process.env.DEBUG_RECORD_ID || ''; -const app = new Hono(); -app.get('/api/health', (c) => c.json({ status: 'ok' })); -app.post('/api/debug/increment-hono', async (c) => { - const auth = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }) - }); - const { token } = await auth.json(); - const record = await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, { - headers: { Authorization: `Bearer ${token}` } - }); - const data = await record.json(); - const current = data.hono_count || 0; - await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ hono_count: current + 1 }) - }); - return c.json({ ok: true }); -}); -const port = parseInt(process.env.PROXY_PORT || '3456', 10); -serve({ fetch: app.fetch, port }, (info) => { - console.log(`Hono proxy listening on http://localhost:${info.port}`); -}); -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/proxy/dist/index.js.map b/proxy/dist/index.js.map deleted file mode 100644 index 36de1d6..0000000 --- a/proxy/dist/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,uBAAuB,CAAC;AAC7D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC;AACtD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC;AAC5D,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,CAAC;AAE1D,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;AAEvB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AAExD,GAAG,CAAC,IAAI,CAAC,2BAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;IACjD,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,iDAAiD,EAAE;QACpF,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;KACnE,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;IAEpC,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACxF,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE;KAC7C,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACjC,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;IAErC,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACzE,MAAM,EAAE,OAAO;QACf,OAAO,EAAE;YACR,cAAc,EAAE,kBAAkB;YAClC,aAAa,EAAE,UAAU,KAAK,EAAE;SAChC;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,EAAE,CAAC;KACjD,CAAC,CAAC;IAEH,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7B,CAAC,CAAC,CAAC;AAEH,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;AAE5D,KAAK,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,EAAE;IAC1C,OAAO,CAAC,GAAG,CAAC,4CAA4C,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;AACtE,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/proxy/package.json b/proxy/package.json index 19da01e..679e3a3 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "seed": "tsx scripts/seed.ts" }, "dependencies": { "@hono/node-server": "^1.13.0", diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts new file mode 100644 index 0000000..2801cd2 --- /dev/null +++ b/proxy/scripts/seed.ts @@ -0,0 +1,314 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +interface FieldDef { + name: string + type: string + required?: boolean + unique?: boolean + max?: number + min?: number + values?: string[] + maxSelect?: number + collectionId?: string + cascadeDelete?: boolean +} + +interface CollectionDef { + name: string + type: string + fields: FieldDef[] + listRule?: string | null + viewRule?: string | null + createRule?: string | null + updateRule?: string | null + deleteRule?: string | null +} + +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; +} + +function text(name: string, required = false): FieldDef { + return { name, type: "text", required }; +} + +function uniqueText(name: string): FieldDef { + return { name, type: "text", required: true, unique: true }; +} + +function number(name: string, required = false): FieldDef { + return { name, type: "number", required }; +} + +function bool(name: string): FieldDef { + return { name, type: "bool" }; +} + +function date(name: string): FieldDef { + return { name, type: "date" }; +} + +function jsonField(name: string): FieldDef { + return { name, type: "json" }; +} + +function select(name: string, values: string[], required = false): FieldDef { + return { name, type: "select", required, values, maxSelect: 1 }; +} + +function rel(name: string, collectionId: string, required = false): FieldDef { + return { + name, + type: "relation", + required, + collectionId, + maxSelect: 1, + cascadeDelete: false, + }; +} + +async function main() { + console.log("Connecting to PB at", PB_ENDPOINT); + const token = await getSuperadminToken(); + console.log("Authenticated as superadmin\n"); + + const ids: Record = {}; + + // ── Pass 1: Independent collections ── + console.log("--- Pass 1: Independent collections ---"); + + ids.fams = await createCollection(token, { + name: "fams", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + text("name", true), + uniqueText("slug"), + text("inviteCode"), + text("stripeCustomerId"), + jsonField("featureFlags"), + ], + }); + + // ── Pass 2: Collections that reference fams ── + console.log("\n--- Pass 2: fam-scoped collections ---"); + + ids.settings = await createCollection(token, { + name: "settings", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("webhookUrl"), + ], + }); + + ids.members = await createCollection(token, { + name: "members", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("name", true), + text("color"), + text("deviceToken"), + text("deviceTokenHint"), + ], + }); + + ids.chore_templates = await createCollection(token, { + name: "chore_templates", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("name", true), + text("description"), + select("defaultFrequency", ["daily", "weekly"], true), + select("defaultType", ["points", "money"], true), + number("defaultValue", true), + ], + }); + + ids.fam_admins = await createCollection(token, { + name: "fam_admins", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("userId", true), + text("email", true), + text("name"), + text("color"), + ], + }); + + ids.bonus_configs = await createCollection(token, { + name: "bonus_configs", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("name", true), + text("description"), + select("target", ["individual", "competitive"], true), + select("type", ["threshold", "count", "manual"], true), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", true), + number("criteriaValue"), + select("period", ["weekly", "monthly"]), + select("status", ["active", "archived"], true), + ], + }); + + // ── Pass 3: Collections with member/template deps ── + console.log("\n--- Pass 3: Nested collections ---"); + + ids.weekly_history = await createCollection(token, { + name: "weekly_history", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + date("weekStart"), + number("pointsEarned"), + number("moneyEarned"), + number("choresCompleted"), + number("bonusEarned"), + ], + }); + + ids.rewards = await createCollection(token, { + name: "rewards", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + rel("bonusConfigId", ids.bonus_configs!), + text("label", true), + number("value", true), + select("rewardType", ["cash", "prize", "points"], true), + bool("claimed"), + date("claimedAt"), + text("date"), + ], + }); + + ids.assigned_chores = await createCollection(token, { + name: "assigned_chores", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + rel("templateId", ids.chore_templates!, true), + select("frequency", ["daily", "weekly"], true), + select("type", ["points", "money"], true), + number("value", true), + text("customName"), + ], + }); + + ids.completions = await createCollection(token, { + name: "completions", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + rel("assignedChoreId", ids.assigned_chores!, true), + date("date"), + ], + }); + + 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 new file mode 100644 index 0000000..df6545b --- /dev/null +++ b/proxy/scripts/test-admin.ts @@ -0,0 +1,99 @@ +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 new file mode 100644 index 0000000..3645911 --- /dev/null +++ b/proxy/scripts/test-auth.ts @@ -0,0 +1,63 @@ +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/index.ts b/proxy/src/index.ts index 05a0f32..32e00e2 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -1,56 +1,1086 @@ +import crypto from "node:crypto"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; - -import { - SERVER_IP, - PB_PORT, - PB_EMAIL, - PB_PASSWORD, - DEBUG_RECORD_ID, -} from "../../config.ts"; +import { pb } from "./pb.ts"; +import { migrate } from "./migrate.ts"; const app = new Hono(); -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; app.get("/api/health", (c) => c.json({ status: "ok" })); -app.post("/api/increment-hono", async (c) => { - const auth = 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 { token } = await auth.json(); +function handleError(c: any, err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + return c.json({ error: msg }, 500); +} - const record = await fetch( - `${PB_ENDPOINT}/api/collections/debug/records/${DEBUG_RECORD_ID}`, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ); - const data = await record.json(); - const current = data.hono_count || 0; +// ── Helpers ───────────────────────────────────────────── - await fetch( - `${PB_ENDPOINT}/api/collections/debug/records/${DEBUG_RECORD_ID}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ hono_count: current + 1 }), - }, - ); +function weekStart(payday: number = 1): string { + const d = new Date(); + const day = d.getDay(); + const diff = d.getDate() - ((day - payday + 7) % 7); + d.setDate(diff); + return d.toISOString().slice(0, 10); +} - return c.json({ ok: true }); +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): string { + if (period === "weekly") return weekStart(payday); + if (period === "monthly") return monthStart(); + return weekStart(payday); +} + +function periodEnd(period: string, start: string): string { + if (period === "weekly") { + const d = new Date(start); + d.setDate(d.getDate() + 6); + return d.toISOString().slice(0, 10); + } + if (period === "monthly") { + const m = start.slice(0, 7); + return monthEnd(m); + } + return start; +} + +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; + } +} + +// ── 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 admins = await pb.getList("fam_admins", `famId = '${famId}' && userId = '${userId}'`); + if (!admins.items?.length) { + return c.json({ error: "Unauthorized" }, 401); + } + c.set("famId", famId); + return next(); +} + +async function requireDeviceToken(c: any, next: any) { + const deviceToken = c.req.header("x-device-token"); + const famId = c.req.header("x-device-famid"); + if (!famId || !deviceToken) { + return c.json({ error: "x-device-token and x-device-famid headers required" }, 400); + } + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList("members", `famId = '${famId}' && deviceToken = '${hashHex}'`); + const member = members.items?.[0]; + if (!member) return c.json({ error: "Invalid device token" }, 401); + c.set("famId", famId); + c.set("memberId", member.id); + 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 = famName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); + const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + const user = await pb.createUser(email, password); + const fam = await pb.create("fams", { name: famName, slug, inviteCode, featureFlags: {} }); + const adminName = parentName || email.split("@")[0]; + const admin = await pb.create("fam_admins", { famId: fam.id, userId: user.id, email, 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: admin.id, memberName: admin.name, memberColor: admin.color, 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 admins = await pb.getList("fam_admins", `userId = '${userId}'`); + const admin = admins.items?.[0]; + if (!admin) return c.json({ error: "No fam found for user" }, 404); + const fams = await pb.getList("fams", `id = '${admin.famId}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + // Update fam_admins name/color on login + const defaultName = email.split("@")[0]; + const adminPatch: Record = { name: defaultName, color: "#6366f1" }; + if (!admin.email) adminPatch.email = email; + const updatedAdmin = await pb.update("fam_admins", admin.id, adminPatch); + return c.json({ + famId: fam.id, famSlug: fam.slug, userId, token: authResult.token, + memberId: updatedAdmin.id, memberName: updatedAdmin.name, memberColor: updatedAdmin.color, + role: "parent", + }); + } catch (err) { return handleError(c, err); } +}); + +async function joinMemberFlow(famId: string, name: string, deviceToken: string) { + const existing = await pb.getList("members", `famId = '${famId}' && name = '${name}'`); + const slot = existing.items?.[0]; + if (!slot) throw new Error(`No member named "${name}" in this family`); + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const tokenHint = deviceToken.substring(0, 8); + await pb.update("members", slot.id, { deviceToken: hashHex, deviceTokenHint: tokenHint }); + const newCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + await pb.update("fams", famId, { inviteCode: newCode }); + return { memberId: slot.id, deviceToken, name: slot.name, inviteCode: newCode }; +} + +app.post("/api/members/join", async (c) => { + try { + const { inviteCode, name, deviceToken } = await c.req.json(); + if (!inviteCode || !name || !deviceToken) return c.json({ error: "inviteCode, name, deviceToken required" }, 400); + const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Invalid invite code" }, 404); + const result = await joinMemberFlow(fam.id, name, deviceToken); + return c.json({ famId: fam.id, famSlug: fam.slug, ...result }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/direct-join", async (c) => { + try { + const { inviteCode, name } = await c.req.json(); + if (!inviteCode || !name) return c.json({ error: "inviteCode, name required" }, 400); + const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Invalid invite code" }, 404); + const deviceToken = crypto.randomUUID(); + const result = await joinMemberFlow(fam.id, name, deviceToken); + return c.json({ famId: fam.id, famSlug: fam.slug, ...result }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/verify", async (c) => { + try { + const { famId, deviceToken } = await c.req.json(); + if (!famId || !deviceToken) return c.json({ error: "famId, deviceToken required" }, 400); + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList("members", `famId = '${famId}' && deviceToken = '${hashHex}'`); + const member = members.items?.[0]; + if (!member) return c.json({ error: "Invalid device token" }, 401); + return c.json({ famId: member.famId, memberId: member.id, name: member.name }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/verify-token", async (c) => { + try { + const { deviceToken, famSlug } = await c.req.json(); + if (!deviceToken || !famSlug) return c.json({ error: "deviceToken, famSlug required" }, 400); + const fams = await pb.getList("fams", `slug = '${famSlug}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList("members", `famId = '${fam.id}' && deviceToken = '${hashHex}'`); + const member = members.items?.[0]; + if (!member) return c.json({ error: "Invalid device token" }, 401); + return c.json({ famId: fam.id, memberId: member.id, name: member.name, color: member.color }); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/members/me", requireDeviceToken, 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("members", 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("members", `famId = '${famId}'`); + 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 record = await pb.create("members", { famId, ...body }); + 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("members", 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("members", 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 { id } = c.req.param(); + await pb.delete("assigned_chores", 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, inviteCode: fam.inviteCode, payday: fam.payday }); + } 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 = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); + 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) { + const payday = Number(body.payday); + if (payday < 0 || payday > 6 || !Number.isInteger(payday)) + return c.json({ error: "payday must be 0-6" }, 400); + const record = await pb.update("fams", famId, { payday }); + return c.json({ payday: record.payday }); + } + 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 }); +}); + +app.post("/api/admin/:famId/regen-invite", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + const fams = await pb.getList("fams", `id = '${famId}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + await pb.update("fams", famId, { inviteCode }); + return c.json({ inviteCode }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Weekly Summary ────────────────────────────── + +app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const payday = await getFamPayday(famId); + const ws = weekStart(payday); + const [members, assigned, completions] = await Promise.all([ + pb.getList("members", `famId = '${famId}'`), + 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' && claimed = true`); + rewardPointsList = pointRewards.items; + } catch {} // schema may not have rewardType/claimed 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 = members.items.map((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); + + return { + memberId: m.id, + memberName: m.name, + memberColor: m.color, + pointsEarned: weekPoints + bonusPoints, + moneyEarned: weekMoney, + choresCompleted: memberCompletions.length, + totalChores: memberAssignments.length, + dayPoints, + dayCompletions, + }; + }); + + return c.json({ weekStart: ws, daysInWeek, summaries }); + + return c.json({ weekStart: ws, summaries }); + } 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(); + const record = await pb.create("bonus_configs", { famId, ...body, phase: body.phase || "ready", 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(); + 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.update("bonus_configs", id, { status: "archived" }); + 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 = { phase: "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 body = await c.req.json(); + const phase = body.phase || "completed"; + const record = await pb.update("bonus_configs", id, { phase, completedAt: phase === "completed" ? new Date().toISOString() : null }); + 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); + let configsData: any = { items: [] }; + try { + configsData = await pb.getList("bonus_configs", `famId = '${famId}' && phase = 'active'`); + } catch {} + const [members, assigned, completions] = await Promise.all([ + pb.getList("members", `famId = '${famId}'`), + 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) : ""; + 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, claimed: teamReward.claimed } + : null, + state: teamReward + ? (teamReward.claimed ? "claimed" : "unclaimed") + : "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, claimed: memberReward.claimed } + : null, + state: memberReward + ? (memberReward.claimed ? "claimed" : "unclaimed") + : "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}' && phase = 'active' && type != 'manual'`)).items; + } catch { return; } + if (!configs.length) return; + + const [allMembers, allAssigned, allCompletions] = await Promise.all([ + pb.getList("members", `famId = '${famId}'`), + 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); + + for (const cfg of configs) { + const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval) : ""; + 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); + + if (cfg.target === "individual") { + const targetMembers = cfg.memberId + ? allMembers.items.filter((m: any) => m.id === cfg.memberId) + : allMembers.items; + for (const m of targetMembers) { + if (existingRewards.find((r: any) => r.memberId === m.id)) continue; + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + let achieved = false; + if (cfg.type === "threshold") { + const total = 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); + achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue); + } else if (cfg.type === "count") { + achieved = cfg.criteriaValue > 0 && memberCompletions.length >= Number(cfg.criteriaValue); + } + if (achieved) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).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, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + } + } + } else if (cfg.target === "collaborative") { + if (existingRewards.length > 0) continue; + 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; + } + if (cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue)) { + for (const m of allMembers.items) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).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, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + } + } + } 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]; + if (winner && !existingRewards.length) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).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, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + } + } + } +} + +// ── 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 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); + 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}' && phase = '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("members", `famId = '${famId}'`); + + 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.claimed)) { + return c.json({ error: `Already issued and pending for ${m.name}` }, 400); + } + if (cfg.occurrence === "recurring" && cfg.period) { + const payday = await getFamPayday(famId); + const pStart = periodStart(cfg.period, payday); + 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) / 100).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, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + created.push(record); + } + + return c.json({ triggered: true, created: created.length, records: created }); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Completion Toggle ──────────────────────────── + +app.post("/api/completions/toggle", requireDeviceToken, 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); + } + const nextDay = new Date(new Date(date + "T00:00:00Z").getTime() + 86400000).toISOString().slice(0, 10); + const existing = await pb.getList( + "completions", + `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${date}' && date < '${nextDay}'`, + ); + 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 }); + 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 chores (for kanban) ────────────────────── + +app.post("/api/members/my-chores", requireDeviceToken, 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}' && phase = '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: 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}' && phase = '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, { claimed: true, claimedAt: now }); + return c.json(record); + } 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); } +}); + +// ── Admin: Send notification message ──────────────────── + +app.get("/api/admin/:famId/profile", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const userId = c.req.header("x-session-userid"); + const admins = await pb.getList("fam_admins", `famId = '${famId}' && userId = '${userId}'`); + const admin = admins.items?.[0]; + if (!admin) return c.json({ error: "Admin not found" }, 404); + return c.json({ id: admin.id, name: admin.name, color: admin.color, email: admin.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 admins = await pb.getList("fam_admins", `famId = '${famId}' && userId = '${userId}'`); + const admin = admins.items?.[0]; + if (!admin) 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("fam_admins", admin.id, update); + return c.json({ id: record.id, name: record.name, color: record.color, email: record.email || "" }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/send-message", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + if (!body.memberId || !body.message) { + return c.json({ error: "memberId and message required" }, 400); + } + const record = await pb.create("notifications", { + famId, + memberId: body.memberId, + message: body.message, + read: false, + }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Claim a reward ───────────────────────────── + +app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => { + try { + const { id } = c.req.param(); + const famId = c.get("famId"); + const memberId = c.get("memberId"); + const now = new Date().toISOString(); + const record = await pb.update("rewards", id, { claimed: true, claimedAt: now }); + // Create notification for admin + const members = await pb.getList("members", `famId = '${famId}' && id = '${memberId}'`); + const memberName = members.items?.[0]?.name || "Unknown"; + const msg = `Claim made by ${memberName}`; + await pb.create("notifications", { famId, memberId, message: msg, read: false }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Notifications ────────────────────────────── + +app.get("/api/members/notifications", requireDeviceToken, async (c) => { + try { + const famId = c.get("famId"); + const memberId = c.get("memberId"); + const data = await pb.getList("notifications", `famId = '${famId}' && memberId = '${memberId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/notifications/:id/dismiss", requireDeviceToken, async (c) => { + try { + const { id } = c.req.param(); + const record = await pb.update("notifications", id, { read: true }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Start server ───────────────────────────────────────── + const port = parseInt(process.env.PROXY_PORT || "3456", 10); +const SERVER_IP = "192.168.1.225"; -serve({ fetch: app.fetch, port }, (info) => { - console.log(`Hono proxy listening on ${SERVER_IP}:${info.port}`); +serve({ fetch: app.fetch, port }, async (info) => { + console.log(`Hono proxy listening on ${SERVER_IP}:${info.port}`); + try { + await migrate(); + } catch (e) { + console.error("[migrate] Failed:", e); + } }); diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts new file mode 100644 index 0000000..b1808f3 --- /dev/null +++ b/proxy/src/migrate.ts @@ -0,0 +1,557 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +let token: string | null = null; + +async function auth(): Promise { + if (token) return token; + const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); + token = data.token; + return token!; +} + +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}` }, + }); + const data = await res.json(); + return data?.items?.[0] || null; +} + +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}` }, + 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 collection: ${col.name}`); +} + +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}` }, + body: JSON.stringify(col), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); + console.log(` ✓ Updated collection: ${col.name || id}`); +} + +export async function migrate(): Promise { + console.log("[migrate] Checking PB collection schemas..."); + + // ── 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", "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("schedule"); + + if (needTargetUpdate || needPeriodUpdate) { + console.log("[migrate] Updating bonus_configs fields..."); + if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"]; + if (needPeriodUpdate) periodField.values = ["schedule", "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)`); + } + + // ── 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`); + } + } + + // ── 4. Create notifications collection if missing ── + const notifCol = await getCollection("notifications"); + if (!notifCol) { + const famsCol = await getCollection("fams"); + if (!famsCol) throw new Error("fams collection not found"); + const membersCol = await getCollection("members"); + if (!membersCol) throw new Error("members collection not found"); + console.log("[migrate] Creating notifications collection..."); + await createCollection({ + name: "notifications", + 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: "memberId", type: "relation", required: true, collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false }, + { name: "message", type: "text", required: true }, + { name: "read", type: "bool", required: false }, + ], + }); + } else { + console.log(` ↳ notifications 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`); + } + } + + // ── 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. Add phase field to bonus_configs ── + if (bonusConfigsCol) { + const hasPhase = bonusConfigsCol.fields.some((f: any) => f.name === "phase"); + if (!hasPhase) { + console.log("[migrate] Adding phase field to bonus_configs..."); + bonusConfigsCol.fields.push({ + name: "phase", type: "select", required: true, + values: ["template", "ready", "active", "completed"], maxSelect: 1, + }); + 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, + }); + + // Backfill existing records → phase = "active" + 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(); + if (data?.items?.length) { + for (const rec of data.items) { + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ phase: "active" }), + }); + } + console.log(` ✓ Backfilled ${data.items.length} bonus_configs → phase=active`); + } + } else { + console.log(` ↳ bonus_configs.phase already exists`); + } + } + + // ── 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`); + } + } + + console.log("[migrate] Done"); +} diff --git a/proxy/src/pb.ts b/proxy/src/pb.ts new file mode 100644 index 0000000..9942ec6 --- /dev/null +++ b/proxy/src/pb.ts @@ -0,0 +1,94 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +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}`); + if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status}`); + }, + + async getList(collection: string, filter = "") { + const params = new URLSearchParams(); + if (filter) params.set("filter", filter); + params.set("perPage", "200"); + const res = await request("GET", `/api/collections/${collection}/records?${params}`); + 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, + }); + }, +};
{subtitle}
{error?.message || 'An unexpected error occurred.'}
Try logging out and signing in again.
No manual bonus configs.
No outstanding claims
Response:
Pick a color:
Loading...
{error}
All done!
Nothing yet
Nothing to claim
Drag a template here
{form.error}
No templates yet
Drop a chore here
Changes will also update all existing assigned chores using this template.
+ Not authenticated. Use the link from your invite. +
Saved!
+ To change your device or sign in on a new device, use your invite link. +
+ Contact your family admin to change your token or reset your account. +
No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.
The week starts on this day for tracking weekly chores and bonus cycles.
{fam?.inviteCode || '...'}
Re-joining a member will disconnect their old device.
{selectedInviteUrl}
Send an email invitation for another parent to join as an admin.
They will set up their own password on first login.
{fam.inviteCode}
Enter your name to join. It must match a member slot created by your admin.
Click below to join as {memberName}
Don't have a family yet? Create one
Already have an account? Log in