Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cb6a3ea5 | |||
| 69a233f1dc | |||
| 3006a45253 | |||
| cacfa3099b | |||
| 31fd9cce38 | |||
| fb18593ac5 | |||
| 5204e7bdbc | |||
| e2b99c45b6 | |||
| 15535a7b0e | |||
| 55fe84a8d9 | |||
| c73ced7894 | |||
| 3dd94b8a7c | |||
| bc23e112f6 |
+9
-6
@@ -1,16 +1,19 @@
|
|||||||
# Runtime env for the SvelteKit + Hono app.
|
# Runtime env for the SvelteKit app.
|
||||||
#
|
#
|
||||||
# The app's own loopback URLs are constants in code (PROXY_URL / PB_ENDPOINT);
|
# The app's own loopback URL (PB_ENDPOINT) is computed in code; only these are
|
||||||
# ports live in config.ts for the proxy. Only these are real env vars:
|
# real env vars:
|
||||||
|
|
||||||
# PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123.
|
# PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123.
|
||||||
PB_EMAIL=
|
PB_EMAIL=
|
||||||
PB_PASSWORD=
|
PB_PASSWORD=
|
||||||
# Public: the dev machine's IP where PB + the dev proxy run. Change this when
|
# Server-only secret used to derive a child member's PB password from
|
||||||
# your remote IP changes — the browser (pocketbase.ts) and pb-admin read it.
|
# (famSlug + username). Never expose client-side. OTP is the access gate.
|
||||||
|
MEMBER_SECRET=
|
||||||
|
# Public: the dev machine's IP where PB runs. Change this when your remote IP
|
||||||
|
# changes — the browser (pocketbase.ts) and server-side reads use it.
|
||||||
# Prod ignores this (uses /pb via nginx). Default: 192.168.1.225.
|
# Prod ignores this (uses /pb via nginx). Default: 192.168.1.225.
|
||||||
SERVER_IP=192.168.1.225
|
SERVER_IP=192.168.1.225
|
||||||
|
|
||||||
# docker-compose (staging) — host-side deploy config, never baked into the image.
|
# docker-compose (staging) — host-side deploy config, never baked into the image.
|
||||||
PORT=3001 # public port to publish (nginx container listens on 3001)
|
PORT=3001 # public port to publish (nginx container listens on 3001)
|
||||||
PB_DATA=./pb_data # where to persist PocketBase data on the host
|
PB_DATA=./pb_data # where to persist PocketBase data on the host
|
||||||
@@ -12,69 +12,73 @@
|
|||||||
|
|
||||||
- SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001)
|
- SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001)
|
||||||
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
|
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
|
||||||
- Stripe one-time donations
|
- Stripe one-time donations — **not implemented** (only `settings.webhookUrl` exists)
|
||||||
- Coolify CRON → `GET /api/weekly-cron`
|
- Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`)
|
||||||
- Deployment: Coolify, Cloudflare DNS
|
- Deployment: Coolify, Cloudflare DNS
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
| Role | Auth | Session | Record in |
|
| Role | Auth | Session | Record in |
|
||||||
| -------------- | -------------------------- | -------------------------- | ------------ |
|
| -------------- | --------------------------------------------- | -------------------------- | ------------------------- |
|
||||||
| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` |
|
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
|
||||||
| Member (child) | Invite code + device token | `device_token` cookie only | `members` |
|
| Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) |
|
||||||
|
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
||||||
|
|
||||||
- **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`.
|
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
|
||||||
- **Members** (children) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie.
|
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**.
|
||||||
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard). Not an app role.
|
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
|
||||||
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session cookie — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
|
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
|
||||||
|
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
|
||||||
|
|
||||||
## PB Collections (all scoped by `famId`)
|
## PB Collections (all scoped by `famId`; child/member = `users` row)
|
||||||
|
|
||||||
- `fams` — name, slug, inviteCode, stripeCustomerId, featureFlags
|
- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
|
||||||
- `members` — famId, name, color, deviceToken(hashed), deviceTokenHint
|
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`)
|
||||||
|
- `fams` — name, slug, stripeCustomerId, featureFlags
|
||||||
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
||||||
- `assigned_chores` — famId, memberId, templateId, frequency, value
|
- `assigned_chores` — famId, userId, templateId, frequency, value
|
||||||
- `completions` — famId, memberId, assignedChoreId, date
|
- `completions` — famId, userId, assignedChoreId, date
|
||||||
- `weekly_history` — famId, memberId, weekStart, pointsEarned, moneyEarned
|
- `weekly_history` — famId, userId, weekStart, pointsEarned, moneyEarned
|
||||||
- `rewards` — famId, memberId, source, label, value, claimed, claimedAt
|
- `rewards` — famId, userId, source, label, value, claimed, claimedAt, claimable, settleDate
|
||||||
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerMemberId
|
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
|
||||||
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
|
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
|
||||||
|
|
||||||
|
> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
```
|
```
|
||||||
/ Landing (SaaS marketing)
|
/ Landing (SaaS marketing)
|
||||||
/admin Admin panel - statistic dashboard, and any donations made
|
/admin Platform super-admin stats dashboard (and any donations)
|
||||||
/join/:code Member invite code
|
/login · /logout Parent email/password login / logout
|
||||||
/join/:code/:member Member invite with pre-selected member
|
/signup Parent + family signup
|
||||||
|
/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
||||||
/{fam} Fam dashboard
|
/{fam} Fam dashboard
|
||||||
/{fam}/admin Admin panel
|
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
|
||||||
/{fam}/:username Member kanban & admin dashboard (role determined by session)
|
/{fam}/{username}/chores Chore templates & assignment grid
|
||||||
/{fam}/:username/preferences User preferences (admin→fam_admins, member→members)
|
/{fam}/{username}/ledger Rewards / chores / todos ledger
|
||||||
/{fam}/:username/settings Family admin settings (session required)
|
/{fam}/{username}/bonuses Bonus configs & evaluation
|
||||||
/{fam}/:username/chores Chore templates & assignment grid
|
/{fam}/{username}/preferences User preferences (parent→users, member→users)
|
||||||
/{fam}/:username/rewards Rewards overview
|
/{fam}/{username}/settings Family admin settings (parent only)
|
||||||
/{fam}/:username/bonuses Bonus configs & evaluation
|
/api/* Hono proxy (data layer; webhooks/CRON not implemented)
|
||||||
/api/* Hono proxy (webhooks, CRON)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
### Reads (both roles)
|
### Reads (both roles)
|
||||||
|
|
||||||
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie).
|
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via the `pb_token` cookie / seeded `initPb(token)`).
|
||||||
- **Child (member):** `famStore.init()` fetches all collections via PB SDK — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections.
|
- **Child (member):** `famStore.init()` fetches all collections via PB SDK — authenticated via their `pb_token` (role `child`). Family-scoped collections have public `listRule` / `viewRule`, so reads work regardless; `.subscribe()` works for both roles.
|
||||||
- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
|
- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
|
||||||
|
|
||||||
### Writes (both roles go through Hono proxy)
|
### Writes
|
||||||
|
|
||||||
- **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT)
|
- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer <pb_token>`)
|
||||||
- **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`)
|
- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`)
|
||||||
- **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`)
|
- **Member updates:** Browser → Hono proxy → PB (auth via `Bearer <pb_token>`)
|
||||||
- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met
|
- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met
|
||||||
- **Weekly CRON:** Coolify → `GET /api/weekly-cron` on Hono → Hono queries PB, computes summaries, upserts weekly_history
|
- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented.
|
||||||
- **Stripe donate:** Browser → Hono `/api/stripe/create-checkout` → Stripe → Hono webhook → update fam
|
- **Stripe / WhatsApp:** not implemented — only the `settings.webhookUrl` field exists.
|
||||||
- **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface
|
|
||||||
|
|
||||||
### UI reactivity
|
### UI reactivity
|
||||||
|
|
||||||
@@ -150,8 +154,8 @@ Two patterns based on who's acting:
|
|||||||
|
|
||||||
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
|
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
|
||||||
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- |
|
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- |
|
||||||
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header |
|
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
|
||||||
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `session` cookie |
|
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie |
|
||||||
|
|
||||||
**Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
|
**Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
|
||||||
|
|
||||||
@@ -195,7 +199,7 @@ All admin and member pages use the following pattern:
|
|||||||
|
|
||||||
- Every collection query includes `famId = @request.auth.famId` filter
|
- Every collection query includes `famId = @request.auth.famId` filter
|
||||||
- Super admin bypasses famId filter (access via PB admin API)
|
- Super admin bypasses famId filter (access via PB admin API)
|
||||||
- `deviceToken` stored as SHA-256 hash; never log raw tokens
|
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets.
|
||||||
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
|
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
|
||||||
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
|
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
|
||||||
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
|
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# FamChore v2 — Development Memory
|
# FamChore v2 — Development Memory
|
||||||
|
|
||||||
|
## 2026-08-17 — Schema collapse to single source of truth (Option 1)
|
||||||
|
|
||||||
|
- **Decision**: abandoned incremental migration history. `SCHEMA_PLAN` (`shared/pb/schema.ts`) is now the true, current schema; `migrate.ts` collapsed from 2096 → 183 lines to a **fresh-only bootstrap** (`ensureSchema()` skips if `fams` exists — no incremental steps). The app isn't live and data is disposable, so there's nothing to preserve; a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
||||||
|
- **Folded the net effect of ~40 hand-written steps into `SCHEMA_PLAN`** (verified against the live PB): `fams.{payday,lastIssued,paydayTime,timezone}` (dropped stale `seasons`), `settings.simulateEow`, `messages.clientId`, `assigned_chores.{isTodo,startDate,completeBy}`. `bonus_configs.memberId`, `weekly_history/rewards/assigned_chores/completions.memberId` → `users`.
|
||||||
|
- **Out of `SCHEMA_PLAN`** (handled in `migrate.ts`): the native `users` auth collection (custom `famId`/`role`/`username`/`color` fields + `username` unique index + password-auth identity + famId-scoped rules, via `ensureUsers`) and the superuser-only `otp` collection (null rules — `col()` can't express `null`, via `ensureOtp`).
|
||||||
|
- **Gotcha**: `col()` coerces `rules.listRule ?? ""` → can't emit `null` rules, so `otp` stays out of the plan. `ensureSchema` needs the live PB to confirm the true schema (SCHEMA_PLAN was stale before this).
|
||||||
|
- Verify path: wipe dev PB + restart frontend (needs user OK) so `migrateOnBoot` rebuilds from `SCHEMA_PLAN`.
|
||||||
|
|
||||||
|
## 2026-08-17 — Hono proxy removed: everything runs in SvelteKit services
|
||||||
|
|
||||||
|
- **Decision**: deleted the `proxy/` Hono service entirely. All business logic (admin CRUD, member kanban, weekly summary/EOW, bonus evaluation/trigger/progress, rewards claim/issue, chat, payday settlement, debug data-gen) now lives in `frontend/src/lib/server/services/`, grouped **by app area** (not by role): `fam.ts`, `chores.ts`, `completions.ts`, `rewards.ts`, `bonuses.ts`, `chat.ts`, `settings.ts`, `crud.ts`, `debug.ts`, plus a generic per-resource `crud.ts`. `createServices(pb, user)` returns a per-feature binder; `servicesFor(event)` is the shorthand for loads/form actions. **No role guard** — PB collection rules on the acting user's token are the security boundary (the `admin`/`member` split no longer exists as separate files).
|
||||||
|
- **Wiring**: `hono.admin.*` (form actions/loads) and `memberApi.*`/chat are now direct service calls or SvelteKit `/api/*` routes (`completions/toggle`, `members/rewards/[id]/claim`, `members`, `fam/[famId]/payday`, `chat`, `admin/[famId]/assigned-chores`). Browser admin calls (chores grid) hit SvelteKit `/api/admin/*`. The `/api/*` routes read auth straight from `event.locals` (`locals.user` + `createPbClient(locals.pbToken)`) — a separate `actingClient` helper was dropped as redundant since `hooks.server.ts` already resolves the session. No Authorization header; the httpOnly `pb_token` cookie is the auth for same-origin calls.
|
||||||
|
- **Migration relocated**: `proxy/src/migrate.ts` → `frontend/src/lib/server/migrate.ts` (env now via `$app/env/private` + `PB_ENDPOINT` from `pocketbase.ts`), run once per process by `migrate-boot.ts`, kicked off in `hooks.server.ts` (`void migrateOnBoot()`). Schema source of truth remains `shared/pb/schema.ts`.
|
||||||
|
- **Infra**: `pnpm-workspace.yaml` (only `frontend`), root `package.json` (`dev` = `pnpm --filter frontend dev`), `docker/Dockerfile` (no proxy build/deploy), `docker/entrypoint.sh` (no proxy start; app runs schema migration on boot), `docker/nginx.conf` (`/api/` block removed → falls through to `location /` → SvelteKit `:3000`; `/pb/api/` unchanged). Removed `PROXY_URL` env + `PROXY_PORT` from `shared/config.ts` and `frontend/src/env.ts`. Dead `memberApi.myChores`/`requestAll` removed.
|
||||||
|
- **Typecheck**: frontend `svelte-check` = 12 pre-existing canary errors (`.svelte` implicit-any, qrcode decl, RewardType/Frequency casts, signup `string|undefined`); **zero errors in the migration's files**. `pnpm build` (adapter-node) succeeds.
|
||||||
|
- **Note**: dev servers were left running; the now-deleted proxy `tsx watch` (`:3456`) will error and the frontend dev server needs a restart to drop `PROXY_URL`/load `migrateOnBoot` + the removed `/api` Vite proxy.
|
||||||
|
|
||||||
## UI Component Architecture (Jul 2026)
|
## UI Component Architecture (Jul 2026)
|
||||||
|
|
||||||
### Layout Hierarchy
|
### Layout Hierarchy
|
||||||
@@ -47,7 +64,9 @@
|
|||||||
- `/api/admin/signup` now creates a member record for the parent with `role: 'parent'`
|
- `/api/admin/signup` now creates a member record for the parent with `role: 'parent'`
|
||||||
- `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info
|
- `/api/admin/login` returns `memberName`, `memberColor`, `role` alongside session info
|
||||||
- `/api/members/verify-token` returns `role` for the frontend
|
- `/api/members/verify-token` returns `role` for the frontend
|
||||||
- `requireAdmin` middleware unchanged (still checks `fam_admins`)
|
- `requireAdmin` middleware checks `users` (`role='parent' && id = userId`, scoped by `famId`)
|
||||||
|
- **Removed `fam_admins` collection (Aug 2026):** parent identity fully lives on the `users` record (`famId`, `role`, `name`, `color`, `email`). `requireAdmin`, `authorizeFamReq`, `resolveChatActor`, admin `/profile` get/patch, and the legacy proxy `/signup`/`/login` now read/write `users` instead. Signup no longer creates a `fam_admins` row; superadmin stats derive `parentEmail` from `users role='parent'`. Migrate step 34 drops the collection.
|
||||||
|
- **Removed `fams.inviteCode` (Aug 2026):** join flow is OTP-based (`user_configs.otp`), so the stored/displayed/regenerated invite code was never consumed. Removed `randomCode()` at signup, the `/regen-invite` endpoint + `hono.admin.regenInvite` action, the `inviteCode` type/field, and added migrate step 34 to drop the field.
|
||||||
- Frontend sidebar is role-aware: `isParent = session !== null`
|
- Frontend sidebar is role-aware: `isParent = session !== null`
|
||||||
- **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc.
|
- **No more `/admin` prefix** — admin pages live under `/{fam}/{parent-username}/chores` etc.
|
||||||
|
|
||||||
@@ -221,3 +240,23 @@
|
|||||||
- Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`.
|
- Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`.
|
||||||
- **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create.
|
- **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create.
|
||||||
- The 0.39 prod `messages` drift (missing `createdAt`, then `id` "Cannot be blank" after a raw field PATCH) came from schema mismatch during the bump. When migrating 0.25→0.39, reconcile the schema scripts against 0.39's field semantics (incl. system `id` `autogeneratePattern`) instead of patching collections by hand.
|
- The 0.39 prod `messages` drift (missing `createdAt`, then `id` "Cannot be blank" after a raw field PATCH) came from schema mismatch during the bump. When migrating 0.25→0.39, reconcile the schema scripts against 0.39's field semantics (incl. system `id` `autogeneratePattern`) instead of patching collections by hand.
|
||||||
|
|
||||||
|
### 2026-08-15 — Members→users migration, OTP child login, cookie httpOnly, slugify
|
||||||
|
|
||||||
|
- **Migration (members → users, run live + verified):** deleted the `members` collection and repointed the 5 `memberId` relations (`assigned_chores`, `completions`, `rewards`, `weekly_history`, `bonus_configs`) → `users`. Added `users.name` + `users.color`, backfilled child name/color; backfilled parent `role` (`''` → `'parent'`). Scoped `users` rules: list/view = `famId = @request.auth.famId`, update/delete = `famId = @request.auth.famId && @request.auth.role = 'parent'`. Re-runs are idempotent.
|
||||||
|
- **PB relation quirk:** PB forbids changing a relation's target collection in place (`validation_field_relation_change`) — you must **drop the field and re-add it** targeting the new collection in two separate collection updates.
|
||||||
|
- **Child (member) auth:** children are `users` records with `role='child'`, PB `username = {famSlug}:{handle}` (composite, globally unique), `name` = raw display name. Server derives the PB password = `MEMBER_SECRET + famSlug + handle`; the join gate is a 20-min OTP in `user_configs`, then `authWithPassword`. Children now hold a `pb_token` cookie (previously a `device_token` cookie with no session). `SessionUser` gained `username` (set in `hooks.server.ts`, stores `handleOf(record.username)`); the kanban child redirect compares `session.username`, not `session.name`.
|
||||||
|
- **Cookie httpOnly:** `pb_token` is now `httpOnly:true`. The browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` (layout onMount) — **not** from `document.cookie` (the client can no longer read it). Logout is server-side only. Note: the JWT is still shipped to the client in SSR HTML via the `pbToken` prop.
|
||||||
|
- **Typecheck baselines:** frontend `svelte-check` = 20 pre-existing canary errors (chat `json(status)` ResponseInit, `$types` Action/SubmitFunction, qrcode decl, vite.config, RewardType/Frequency casts, implicitly-any); proxy `tsc --noEmit` = pre-existing record-typing + implicit-any errors. No new errors in edited files.
|
||||||
|
- **`crypto.randomUUID()` unavailable over plain HTTP** (non-secure context) → added `genClientId()` fallback (randomUUID if available, else `Date.now().toString(36)+random`) in `chat.svelte.ts`.
|
||||||
|
- **Shared slugify util:** `shared/slugify.ts` (`@shared/slugify` alias) — used by proxy signup + fam rename, frontend signup + settings `renameFam`, and `member-otp.createChild` (child username + password gen). Removed the 3 local duplicate `slugify` helpers.
|
||||||
|
- **Settings UI:** CardGrid/Card are now responsive (media queries + `--grid-cols`/`--card-cols`; Cards use `container-type: inline-size`). Members card split into two columns: add-child form | member list (space-between rows, larger 22px colour circle, "Preview" CTA → `/{famSlug}/{m.username}`, Remove). Family Name card gained an explainer + mono `/{fam.slug}` slug line.
|
||||||
|
- **Hono audit:** the proxy is the live data layer — admin CRUD/reads via `hono.admin.*` (kanban load, bonuses ~17 calls, ledger 6, preferences 2, fam dash 4, settings `complete-week`/`debug/generate-data`), member actions via `memberApi.*` (toggleCompletion/claimReward/payday) + direct `/api` fetches (chores assigned-chores, kanban `/api/members/me`). **Not implemented:** `/api/weekly-cron` CRON, Stripe (`create-checkout` + webhook), WhatsApp — weekly settlement is manual via `complete-week`/`simulateEow`. Chat endpoints exist in the proxy but the frontend talks to PB directly.
|
||||||
|
|
||||||
|
### 2026-08-15 — Signup: multi-step flow restored + family-creation bug fixed
|
||||||
|
|
||||||
|
- **Bug (blocker):** new family creation failed with PB `{"username":{"code":"validation_required","message":"Cannot be blank."}}` — the `users.create` in `/signup` omitted the auth `username` field (PB 0.39 requires it). Reproduced directly against PB: create WITHOUT `username` → `validation_required`; WITH `username` → succeeds.
|
||||||
|
- **Fix:** `signup/+page.server.ts` now includes `username` on the parent `users` create.
|
||||||
|
- **Username convention (composite + handle):** PB `users.username` is the composite `{famSlug}:{handle}` for BOTH parents and children — globally unique (PB auth-identity needs a single-column unique index) even though the URL segment is per-family. `handle(name)` = lowercase, strips all non-`[a-z0-9]` (`"Jakey Boy"` → `jakeyboy`); `slugify()` (hyphenated) is kept only for fam slugs. URL segment = `handleOf(username)` (part after the last `:`) → `/{famSlug}/{handle}`. `name` keeps the raw display name, read from DB via `authRefresh` (not plucked into the cookie). Parent's handle captured at signup step 1 (`yourName`) → `username = famUsername(famSlug, handle(yourName))`; parents authenticate email+password and land on the fam dashboard `/{famSlug}` (not username-routed). Children authenticate via OTP → `authWithPassword(famUsername(...), derivePassword(famSlug, handle))`. Redirects in `login/+page.server.ts`, `[fam]/[username]/+page.server.ts` (parent + child branches) and `preferences/+page.server.ts` use `session.username` (the handle). Member-list URLs in `settings`, `[fam]/+page.svelte`, `[fam]/[username]/+page.svelte` build `/{famSlug}/{handleOf(m.username)}`. `handle`/`handleOf`/`famUsername` live in `shared/slugify.ts` (`@shared/slugify`).
|
||||||
|
- **Restored intended multi-step signup** (from the guide, adapted to current OTP model): `/signup` steps — (1) `?/signup` familyName/yourName/email/password → create fam + parent (name=yourName, username=famUsername(famSlug, handle(yourName))) + settings, set `pb_token`; (2) `?/child` optional child → `issueAccess` returns `{ code, joinUrl }`; (3) show OTP join code + "Go to dashboard" link. Uses named actions + `use:enhance` (callback typed `any` to avoid the pre-existing canary `$types` SubmitFunction error).
|
||||||
|
- **Typecheck:** frontend `svelte-check` stays at 20 pre-existing errors (no new in edited files).
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Items:
|
||||||
|
|
||||||
|
- The hono workhorse - see notes later
|
||||||
|
|
||||||
|
## the hono workhorse
|
||||||
|
|
||||||
|
**Actively used (the workhorse):**
|
||||||
|
|
||||||
|
- **Admin reads/writes** via `hono.admin.*` — used heavily by the child kanban (`+page.server.ts` load: members, chore-templates, assigned-chores, weekly-summary, fam, rewards, bonus-configs, completions, settings), the **bonuses** page (17 calls), **ledger** (6), **preferences** (2), **fam dashboard** (4), plus settings `complete-week` and `debug/generate-data`.
|
||||||
|
- **Member actions** via `memberApi.*` + direct `/api` fetches — `toggleCompletion`, `claimReward`, `payday`, `/api/members/me`, `/api/members/my-chores` (both SSR and browser).
|
||||||
|
- **Direct client calls** — the chores page hits `/api/admin/:famId/assigned-chores` directly; the kanban hits `/api/members/me`.
|
||||||
|
|
||||||
|
We will tackle this as the next feature `feature/migrate-hono-to-kit`
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# Auth architecture
|
||||||
|
|
||||||
|
Current implementation: **SvelteKit directly + PocketBase**. Hono is **not** part of
|
||||||
|
auth today — it is reserved for future services (email, etc.). The `/api` proxy target
|
||||||
|
still points at it, but no auth traffic flows through it.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ BROWSER (Svelte) │
|
||||||
|
│ /login /signup /join forms · $app/forms · enhance │
|
||||||
|
└──────────────────────────┬─────────────────────────────┘
|
||||||
|
│ 1. form action POST
|
||||||
|
│ /login · /signup · /join
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ SVELTEKIT SERVER (Node) │
|
||||||
|
│ │
|
||||||
|
│ hooks.server.ts (runs once per request, first) │
|
||||||
|
│ · read httpOnly cookie pb_token │
|
||||||
|
│ · createPbClient(token) → pb.authRefresh() │
|
||||||
|
│ · → { id, name, username, role, famId, color } │
|
||||||
|
│ · event.locals.user / event.locals.pbToken │
|
||||||
|
│ · route guards: public vs authed vs admin-only │
|
||||||
|
│ │
|
||||||
|
│ +page.server.ts (actions / loads) │
|
||||||
|
│ signup.ts / login.ts / member-otp.ts / session.ts │
|
||||||
|
│ · createSuperClient (signup / OTP, superuser) │
|
||||||
|
│ · createPbClient(token) (server-to-server) │
|
||||||
|
└──────────────────────────┬─────────────────────────────┘
|
||||||
|
│ 2. PB API (REST) │
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ POCKETBASE (SERVER_IP:8090) │
|
||||||
|
│ collections: users (auth) · fams · ... │
|
||||||
|
│ │
|
||||||
|
│ PB is the SOURCE OF TRUTH: │
|
||||||
|
│ · password hashing (bcrypt-style) │
|
||||||
|
│ · JWT token issue + expiry (exp claim) │
|
||||||
|
│ · authRefresh / token rotation │
|
||||||
|
│ · per-collection API rules (authz boundary) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Future (NOT auth): SvelteKit → /api proxy → Hono → email & other services
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roles (in the `users` collection)
|
||||||
|
|
||||||
|
| Role | Auth | Session cookie | Record in |
|
||||||
|
| -------- | --------------------------------------------- | ------------------------- | ---------- |
|
||||||
|
| Admin | PB email + password | `pb_token` (24hr JWT) | `users` (role `parent`) |
|
||||||
|
| Member | Invite OTP + server-derived password | `pb_token` (httpOnly) | `users` (role `child`) |
|
||||||
|
| Superuser| PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
||||||
|
|
||||||
|
- **Admins (parents)** authenticate via email/password → PB JWT in an httpOnly `pb_token`
|
||||||
|
cookie. The session user is `{ id, name, username, role: 'parent', famId, color }`.
|
||||||
|
- **Members (children)** are `users` records with `role='child'`; their PB `username` is the composite `{famSlug}:{handle}` (globally unique auth identity) where `handle` is the whitespace-free lowercase form of their name, and `name` keeps the raw display name. Their PB password is
|
||||||
|
**derived** server-side as `MEMBER_SECRET + famSlug + username` (they never know or type it).
|
||||||
|
Access is gated by a 20-minute OTP in `user_configs`. On join they `authWithPassword` and get
|
||||||
|
the same httpOnly `pb_token` cookie. There is no separate `members` collection anymore.
|
||||||
|
- **Platform superuser** (`_superusers`) is used only server-side by `pb-admin.ts` for
|
||||||
|
cross-family / signup / OTP writes. Not an app role.
|
||||||
|
|
||||||
|
## Request lifecycle (authenticated)
|
||||||
|
|
||||||
|
1. Browser sends request; sends cookie `pb_token` (httpOnly, sameSite=lax, secure in prod).
|
||||||
|
2. `hooks.server.ts` extracts the token.
|
||||||
|
3. `createPbClient(token)` builds a PB client pre-authenticated as that user.
|
||||||
|
4. `pb.collection('users').authRefresh()`:
|
||||||
|
- validates the token (PB JWTs can't be checked offline),
|
||||||
|
- returns the fresh record → `event.locals.user = { id, name, username, role, famId, color }`,
|
||||||
|
- returns a fresh token; if it changed, the cookie is rolled forward.
|
||||||
|
5. Route guard runs (public / authed / admin-only).
|
||||||
|
6. `+page.server.ts` / `+server.ts` use `locals.user` for identity; use the token-backed
|
||||||
|
PB client for any CRUD so PB's collection rules apply.
|
||||||
|
|
||||||
|
Because `pb_token` is httpOnly, the browser PB SDK cannot read it from `document.cookie`.
|
||||||
|
It is instead seeded from the SSR `page.data.pbToken` prop via `initPb(token)` in the
|
||||||
|
`[fam]/+layout.svelte` `onMount`.
|
||||||
|
|
||||||
|
## Signup flow (`/signup`)
|
||||||
|
|
||||||
|
Multi-step form on a single route. All steps run as **form actions** on the server; the
|
||||||
|
`use:enhance` handler only advances the step on a non-failure result.
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 (?/signup) familyName + yourName + email + password
|
||||||
|
· create fams → fam (slug = slugify(familyName))
|
||||||
|
· create users → parent (role 'parent', name = yourName, username = `{famSlug}:{handle(yourName)}`)
|
||||||
|
· create settings
|
||||||
|
· authWithPassword(email, password) → set httpOnly pb_token cookie → step 2
|
||||||
|
|
||||||
|
Step 2 (?/child) child's first name (optional)
|
||||||
|
· issueAccess() → upserts child user + OTP → returns { code, joinUrl } → step 3
|
||||||
|
· or "Skip for now" → dashboard
|
||||||
|
|
||||||
|
Step 3 show OTP join code + joinUrl (+ "Go to dashboard" link)
|
||||||
|
```
|
||||||
|
|
||||||
|
All create calls use the superuser client (`createSuperClient` / `pbAdmin`), which bypasses
|
||||||
|
PB collection rules. PB requires a `username` on `users` auth records — for both parents and
|
||||||
|
children it's the composite `{famSlug}:{handle}` (globally unique so PB's auth-identity
|
||||||
|
unique index holds, even though the URL segment is only per-family), where `handle` is the
|
||||||
|
whitespace-free lowercase form of the name (`"Joe Edhook"` → `joeedhook`). `name` keeps the
|
||||||
|
raw human-entered display name. The URL segment is `handleOf(username)` (part after the last
|
||||||
|
`:`) → `/{famSlug}/{handle}`; parents still authenticate with email+password and land on the
|
||||||
|
fam dashboard `/{famSlug}`.
|
||||||
|
|
||||||
|
## Child join flow (`/{famSlug}/join/{username}?code=…`)
|
||||||
|
|
||||||
|
1. Admin issues a child in Settings → `issueAccess` (`member-otp.ts`):
|
||||||
|
`createChild({ name, famId, famSlug })` upserts the `users` record
|
||||||
|
(`username = {famSlug}:{handle(name)}`, `name` = display, password = derived), and upserts a
|
||||||
|
`user_configs` row with a 20-min `otp`. Returns `{ otp, joinUrl }`.
|
||||||
|
2. The join page auto-fills the OTP from the `?code=` query param; the child submits the form.
|
||||||
|
3. `redeemOtp` verifies the fam slug, the child role, the OTP + its TTL, then
|
||||||
|
`authWithPassword(famUsername(famSlug, handle), derivePassword(famSlug, handle))` and returns a fresh JWT,
|
||||||
|
which is set as the `pb_token` cookie.
|
||||||
|
4. Child is redirected to their own kanban `/{famSlug}/{username}`.
|
||||||
|
|
||||||
|
## Key decisions to replicate in another project
|
||||||
|
|
||||||
|
- **Server-only PB client** lives in `src/lib/server/`; never imported by browser code.
|
||||||
|
Secrets (superuser creds, `MEMBER_SECRET`) stay server-side.
|
||||||
|
- **Absolute PB base URL** in the SDK (e.g. `http://host:8090`), NOT a relative `/pb`.
|
||||||
|
All calls run in Node where relative URLs fail. The Vite `/pb` proxy is a browser-only
|
||||||
|
convenience and is unnecessary for server-side calls.
|
||||||
|
- **Env vars**: read via `$app/env/private` for private vars (declared in `src/env.ts`),
|
||||||
|
never `$app/env/public`.
|
||||||
|
- **`createSuperClient`** = anonymous client + `_superusers` auth, used for signup and OTP
|
||||||
|
writes. Superusers bypass PB collection rules.
|
||||||
|
- **Session cookie**: PB JWT in `pb_token`, `httpOnly:true`; expiry governed by the token's
|
||||||
|
`exp`, cookie `maxAge` is just a ceiling; `authRefresh` rolls it forward.
|
||||||
|
- **Authz boundary** lives in PocketBase collection rules (famId scoping), not just app code.
|
||||||
|
- **Child identity** is a `users` record; `username` (slug) is used for URLs and the derived
|
||||||
|
password, `name` for display. Never store raw `deviceToken`; the OTP gate is transient.
|
||||||
@@ -9,7 +9,6 @@ COPY . .
|
|||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
RUN pnpm --filter frontend build
|
RUN pnpm --filter frontend build
|
||||||
RUN pnpm --filter proxy build
|
|
||||||
|
|
||||||
# Create a standalone production node_modules for frontend
|
# Create a standalone production node_modules for frontend
|
||||||
RUN pnpm --filter frontend deploy --prod /deploy/frontend
|
RUN pnpm --filter frontend deploy --prod /deploy/frontend
|
||||||
@@ -29,9 +28,6 @@ COPY --from=builder /app/frontend/build ./frontend
|
|||||||
COPY --from=builder /deploy/frontend/node_modules ./frontend/node_modules
|
COPY --from=builder /deploy/frontend/node_modules ./frontend/node_modules
|
||||||
COPY --from=builder /deploy/frontend/package.json ./frontend/package.json
|
COPY --from=builder /deploy/frontend/package.json ./frontend/package.json
|
||||||
|
|
||||||
# Proxy is bundled into one JS file by esbuild
|
|
||||||
COPY --from=builder /app/proxy/dist ./proxy
|
|
||||||
|
|
||||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ fi
|
|||||||
# automigrate generates conflicting snapshots on upgraded stores.
|
# automigrate generates conflicting snapshots on upgraded stores.
|
||||||
pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" --automigrate=false &
|
pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" --automigrate=false &
|
||||||
|
|
||||||
# Wait for PB to be healthy before starting the proxy (which runs migrate).
|
# Wait for PB to be healthy before starting the app (which runs the schema
|
||||||
|
# migration on boot).
|
||||||
echo "[entrypoint] Waiting for PocketBase..."
|
echo "[entrypoint] Waiting for PocketBase..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then
|
||||||
@@ -26,10 +27,8 @@ for i in $(seq 1 30); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
# Start the app (frontend + proxy). The proxy auto-runs schema migration.
|
# Start the app (SvelteKit + adapter-node). It auto-runs the schema migration.
|
||||||
# FRONTEND_PORT/PROXY_PORT are set via ENV in the Dockerfile; adapter-node
|
# PORT defaults to 3000 (adapter-node); nginx proxies to it.
|
||||||
# reads PORT, the proxy reads PROXY_PORT.
|
|
||||||
node /app/frontend/index.js &
|
node /app/frontend/index.js &
|
||||||
node /app/proxy/index.js &
|
|
||||||
|
|
||||||
nginx -g 'daemon off;'
|
nginx -g 'daemon off;'
|
||||||
|
|||||||
@@ -11,16 +11,6 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# App's Hono proxy (/api/*).
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:3456;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the
|
# Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the
|
||||||
# /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything
|
# /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything
|
||||||
# else under /pb/) is intentionally NOT proxied, keeping it internal.
|
# else under /pb/) is intentionally NOT proxied, keeping it internal.
|
||||||
|
|||||||
Vendored
+4
-12
@@ -1,20 +1,12 @@
|
|||||||
import { PocketBase } from 'pocketbase';
|
import type { SessionUser } from './lib/server/types';
|
||||||
import type { Session } from './lib/types';
|
|
||||||
|
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
|
||||||
interface Locals {
|
interface Locals {
|
||||||
pb: PocketBase;
|
user: SessionUser | null;
|
||||||
session?: Session | null;
|
pbToken: string | null;
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
+4
-3
@@ -10,10 +10,11 @@ const withDefault = (value: string) => ({
|
|||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
export const variables = defineEnvVars({
|
export const variables = defineEnvVars({
|
||||||
// SSR → Hono proxy (loopback, proxy runs on the same host as SSR).
|
|
||||||
PROXY_URL: { public: true, schema: withDefault('http://127.0.0.1:3456') },
|
|
||||||
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
|
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
|
||||||
// PB superuser creds (server-only).
|
// PB superuser creds (server-only).
|
||||||
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
|
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
|
||||||
PB_PASSWORD: { public: false, schema: withDefault('debug123') }
|
PB_PASSWORD: { public: false, schema: withDefault('debug123') },
|
||||||
|
// Server-only secret used to derive a child member's PB password from
|
||||||
|
// (famSlug + username). Never expose client-side. OTP is the access gate.
|
||||||
|
MEMBER_SECRET: { public: false, schema: withDefault('famchamp-member-secret') }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
|
||||||
|
import type { SessionUser } from '$lib/server/types';
|
||||||
|
import { handleOf } from '@shared/slugify';
|
||||||
|
import { migrateOnBoot } from '$lib/server/migrate-boot';
|
||||||
|
|
||||||
const COOKIE_NAME = 'session';
|
// Run the PB schema migration once at server boot (idempotent).
|
||||||
|
void migrateOnBoot();
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const raw = event.cookies.get(COOKIE_NAME);
|
event.locals.user = null;
|
||||||
if (raw) {
|
event.locals.pbToken = null;
|
||||||
|
|
||||||
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const pb = createPbClient(token);
|
||||||
try {
|
try {
|
||||||
event.locals.session = JSON.parse(raw);
|
// authRefresh() does two jobs in one call:
|
||||||
|
// 1. Verifies the token (PB JWTs can't be checked offline — the
|
||||||
|
// signing secret is per-record and never leaves PB), so this
|
||||||
|
// round trip IS the verification step.
|
||||||
|
// 2. Returns the current record — the only way to get
|
||||||
|
// name/role/famId, since PB doesn't embed custom fields in the
|
||||||
|
// token itself.
|
||||||
|
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
||||||
|
|
||||||
|
event.locals.user = {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name || record.username || '',
|
||||||
|
username: handleOf(record.username || ''),
|
||||||
|
role: record.role || 'parent',
|
||||||
|
famId: record.famId,
|
||||||
|
color: record.color || ''
|
||||||
|
} satisfies SessionUser;
|
||||||
|
event.locals.pbToken = freshToken;
|
||||||
|
|
||||||
|
if (freshToken !== token) {
|
||||||
|
setSessionCookie(event.cookies, freshToken);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
event.cookies.delete(COOKIE_NAME, { path: '/' });
|
// Expired, malformed, or revoked — drop it and treat as logged out.
|
||||||
|
clearSessionCookie(event.cookies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
// Client-only. All /api calls go same-origin (vite proxy in dev, nginx in
|
// Client-only. All /api calls go same-origin (SvelteKit in dev and prod);
|
||||||
// prod). Server-side (SSR) calls use PROXY_URL from $app/env/public instead.
|
// auth rides on the httpOnly `pb_token` cookie, so no token/header needed.
|
||||||
const BASE_URL = '';
|
const BASE_URL = '';
|
||||||
|
|
||||||
async function memberFetch<T = unknown>(
|
async function memberFetch<T = unknown>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
token: string,
|
|
||||||
famId: string,
|
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {};
|
||||||
'x-device-token': token,
|
|
||||||
'x-device-famid': famId,
|
|
||||||
};
|
|
||||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
@@ -25,19 +20,13 @@ async function memberFetch<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const memberApi = {
|
export const memberApi = {
|
||||||
async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) {
|
async toggleCompletion(famId: string, assignedChoreId: string, date: string) {
|
||||||
return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date });
|
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date });
|
||||||
},
|
},
|
||||||
async myChores(token: string, famId: string) {
|
async claimReward(famId: string, rewardId: string) {
|
||||||
return memberFetch('POST', '/api/members/my-chores', token, famId);
|
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
||||||
},
|
},
|
||||||
async claimReward(token: string, famId: string, rewardId: string) {
|
async payday(famId: string) {
|
||||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
|
return memberFetch('POST', `/api/fam/${famId}/payday`);
|
||||||
},
|
},
|
||||||
async requestAllRewards(token: string, famId: string) {
|
};
|
||||||
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
|
|
||||||
},
|
|
||||||
async payday(token: string, famId: string) {
|
|
||||||
return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="card"
|
class="card"
|
||||||
style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}"
|
data-cols={cols}
|
||||||
|
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
|
||||||
class:has-accent={!!accent}
|
class:has-accent={!!accent}
|
||||||
class:scroll-x={scrollX}
|
class:scroll-x={scrollX}
|
||||||
>
|
>
|
||||||
@@ -26,10 +27,14 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.card {
|
.card {
|
||||||
|
grid-column: span var(--card-cols, 1);
|
||||||
|
/* Container so card contents can react to how wide the card actually is */
|
||||||
|
container-type: inline-size;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.card.scroll-x {
|
.card.scroll-x {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -37,6 +42,21 @@
|
|||||||
.card.has-accent {
|
.card.has-accent {
|
||||||
border-top: 3px solid var(--card-accent, #6366f1);
|
border-top: 3px solid var(--card-accent, #6366f1);
|
||||||
}
|
}
|
||||||
|
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
|
||||||
|
@media (min-width: 640px) and (max-width: 1023px) {
|
||||||
|
.card[data-cols='3'],
|
||||||
|
.card[data-cols='4'],
|
||||||
|
.card[data-cols='5'],
|
||||||
|
.card[data-cols='6'] {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Mobile (1-col grid): every card is a full row */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.card {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
@@ -54,4 +74,4 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
let { cols = 3, children }: { cols?: number; children?: any } = $props();
|
let { cols = 3, children }: { cols?: number; children?: any } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card-grid" style="grid-template-columns:repeat({cols}, 1fr)">
|
<div class="card-grid" style="--grid-cols: {cols}">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -10,5 +10,18 @@
|
|||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
|
||||||
}
|
}
|
||||||
</style>
|
/* Tablet: settle to 2 columns */
|
||||||
|
@media (min-width: 640px) and (max-width: 1023px) {
|
||||||
|
.card-grid {
|
||||||
|
--grid-cols: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Mobile: single column */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.card-grid {
|
||||||
|
--grid-cols: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,10 +4,11 @@ const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
|
|||||||
export const pb = new PocketBase(PB_ENDPOINT);
|
export const pb = new PocketBase(PB_ENDPOINT);
|
||||||
pb.autoCancellation(false);
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
export function initPbFromCookie() {
|
// Seed the browser PB singleton with the session token so shared stores can
|
||||||
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/);
|
// do authenticated reads + realtime .subscribe() from the client.
|
||||||
if (match) {
|
export function initRealtimePb(token: string) {
|
||||||
pb.authStore.save(match[1], null);
|
if (token) {
|
||||||
|
pb.authStore.save(token, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { PROXY_URL } from '$app/env/public';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
export function getSession(event: RequestEvent) {
|
export function getSession(event: RequestEvent) {
|
||||||
return event.locals.session;
|
return event.locals.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireAuth(event: RequestEvent) {
|
export function requireAuth(event: RequestEvent) {
|
||||||
@@ -14,72 +14,14 @@ export function requireAuth(event: RequestEvent) {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signup(email: string, password: string, famName: string, parentName?: string) {
|
|
||||||
const res = await fetch(`${PROXY_URL}/api/admin/signup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password, famName, parentName }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Signup failed');
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
|
||||||
console.log(email);
|
|
||||||
const res = await fetch(`${PROXY_URL}/api/admin/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
|
|
||||||
const res = await fetch(`${PROXY_URL}/api/members/join`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ inviteCode, name, deviceToken }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Join failed');
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
|
|
||||||
event.cookies.set('session', JSON.stringify(session), {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
secure: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
|
|
||||||
event.cookies.set('device_token', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 * 365,
|
|
||||||
secure: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setPbTokenCookie(event: RequestEvent, token: string) {
|
|
||||||
event.cookies.set('pb_token', token, {
|
|
||||||
httpOnly: false,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24,
|
|
||||||
secure: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSession(event: RequestEvent) {
|
export function clearSession(event: RequestEvent) {
|
||||||
event.cookies.delete('session', { path: '/' });
|
event.cookies.delete('session', { path: '/' });
|
||||||
event.cookies.delete('pb_token', { path: '/' });
|
event.cookies.delete('pb_token', { path: '/' });
|
||||||
event.cookies.delete('device_token', { path: '/' });
|
event.cookies.delete('device_token', { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the fam slug + admin display name used for the post-login redirect.
|
||||||
|
export async function getFamContext(famId: string) {
|
||||||
|
const fam = await pbAdmin.getOne('fams', famId).catch(() => null);
|
||||||
|
return { famSlug: fam?.slug || famId, famName: fam?.name || '' };
|
||||||
|
}
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
|
||||||
import { PROXY_URL } from '$app/env/public';
|
|
||||||
|
|
||||||
function sessionHeaders(event: RequestEvent): Record<string, string> {
|
|
||||||
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<string, string>
|
|
||||||
) {
|
|
||||||
const res = await fetch(`${PROXY_URL}${path}`, {
|
|
||||||
method,
|
|
||||||
headers: headers || { 'Content-Type': 'application/json' },
|
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
let data: any = {};
|
|
||||||
try {
|
|
||||||
data = text ? JSON.parse(text) : {};
|
|
||||||
} catch {
|
|
||||||
data = { raw: text };
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = data?.error || data?.message || `${method} ${path} failed (HTTP ${res.status})`;
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hono = {
|
|
||||||
admin: {
|
|
||||||
async list(event: RequestEvent, resource: string, famId: string) {
|
|
||||||
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async create(
|
|
||||||
event: RequestEvent,
|
|
||||||
resource: string,
|
|
||||||
famId: string,
|
|
||||||
data: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async update(
|
|
||||||
event: RequestEvent,
|
|
||||||
resource: string,
|
|
||||||
famId: string,
|
|
||||||
id: string,
|
|
||||||
data: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async remove(event: RequestEvent, resource: string, famId: string, id: string) {
|
|
||||||
return request(
|
|
||||||
'DELETE',
|
|
||||||
`/api/admin/${famId}/${resource}/${id}`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async renameFam(event: RequestEvent, famId: string, name: string) {
|
|
||||||
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async updatePayday(
|
|
||||||
event: RequestEvent,
|
|
||||||
famId: string,
|
|
||||||
payday: number,
|
|
||||||
paydayTime?: string,
|
|
||||||
timezone?: string
|
|
||||||
) {
|
|
||||||
return request(
|
|
||||||
'PATCH',
|
|
||||||
`/api/admin/${famId}/fam`,
|
|
||||||
{
|
|
||||||
payday,
|
|
||||||
...(paydayTime !== undefined ? { paydayTime } : {}),
|
|
||||||
...(timezone !== undefined ? { timezone } : {})
|
|
||||||
},
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async fam(event: RequestEvent, famId: string) {
|
|
||||||
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async verify(event: RequestEvent, famId: string) {
|
|
||||||
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async 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 issueAllRewards(event: RequestEvent, famId: string, memberId: string) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/rewards/issue-all`,
|
|
||||||
{ memberId },
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async bonusConfigs(event: RequestEvent, famId: string) {
|
|
||||||
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async bonusConfigProgress(event: RequestEvent, famId: string) {
|
|
||||||
return request(
|
|
||||||
'GET',
|
|
||||||
`/api/admin/${famId}/bonus-configs/progress`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/bonus-configs/evaluate`,
|
|
||||||
{ configId },
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async triggerBonusConfig(
|
|
||||||
event: RequestEvent,
|
|
||||||
famId: string,
|
|
||||||
configId: string,
|
|
||||||
memberId?: string
|
|
||||||
) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/bonus-configs/${configId}/trigger`,
|
|
||||||
{ memberId },
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async assignBonusConfig(
|
|
||||||
event: RequestEvent,
|
|
||||||
famId: string,
|
|
||||||
configId: string,
|
|
||||||
data: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/bonus-configs/${configId}/assign`,
|
|
||||||
data,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async completeBonusConfig(event: RequestEvent, famId: string, configId: string) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/bonus-configs/${configId}/complete`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/bonus-configs/${configId}/destroy`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async bonusConfigTallies(event: RequestEvent, famId: string) {
|
|
||||||
return request(
|
|
||||||
'GET',
|
|
||||||
`/api/admin/${famId}/bonus-configs/tallies`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/completions/${completionId}/revoke`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async memberChores(event: RequestEvent, famId: string, memberId: string) {
|
|
||||||
return request(
|
|
||||||
'GET',
|
|
||||||
`/api/admin/${famId}/members/${memberId}/chores`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async updateMember(
|
|
||||||
event: RequestEvent,
|
|
||||||
famId: string,
|
|
||||||
memberId: string,
|
|
||||||
data: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
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<string, unknown>) {
|
|
||||||
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async updateFam(event: RequestEvent, famId: string, data: Record<string, unknown>) {
|
|
||||||
return request('PATCH', `/api/admin/${famId}/fam`, data, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
|
|
||||||
return request(method, path, body, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async settings(event: RequestEvent, famId: string) {
|
|
||||||
return request('GET', `/api/admin/${famId}/settings`, undefined, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async updateSettings(event: RequestEvent, famId: string, data: Record<string, unknown>) {
|
|
||||||
return request('PATCH', `/api/admin/${famId}/settings`, data, sessionHeaders(event));
|
|
||||||
},
|
|
||||||
async eowPreview(event: RequestEvent, famId: string) {
|
|
||||||
return request(
|
|
||||||
'GET',
|
|
||||||
`/api/admin/${famId}/debug/eow-preview`,
|
|
||||||
undefined,
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { MEMBER_SECRET } from '$app/env/private';
|
||||||
|
import { createSuperClient, createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { famUsername, handle } from '@shared/slugify';
|
||||||
|
|
||||||
|
const OTP_TTL_MS = 20 * 60 * 1000; // 20 minutes
|
||||||
|
|
||||||
|
// A child member's PB password is derived from (secret + famSlug + handle), so
|
||||||
|
// the server can authWithPassword at join time. The user never sees or types it;
|
||||||
|
// OTP is the access gate.
|
||||||
|
export function derivePassword(famSlug: string, handleName: string) {
|
||||||
|
return `${String(MEMBER_SECRET)}${famSlug}${handleName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateOtp() {
|
||||||
|
const n = randomBytes(3).readUIntBE(0, 3) % 1_000_000;
|
||||||
|
return n.toString().padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create (or fetch existing) a child users record. The PB username is the
|
||||||
|
// composite `{famSlug}:{handle}` (globally unique auth identity); `name` keeps
|
||||||
|
// the raw display name. The password is derived from (secret + famSlug + handle)
|
||||||
|
// so the server can authWithPassword at join time.
|
||||||
|
export async function createChild(opts: {
|
||||||
|
famId: string;
|
||||||
|
famSlug: string;
|
||||||
|
name: string;
|
||||||
|
colour?: string;
|
||||||
|
}) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const handleName = handle(opts.name);
|
||||||
|
const username = famUsername(opts.famSlug, handleName);
|
||||||
|
const password = derivePassword(opts.famSlug, handleName);
|
||||||
|
|
||||||
|
let user = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFirstListItem(`famId='${opts.famId}' && username='${username}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await pb.collection('users').create({
|
||||||
|
username,
|
||||||
|
name: opts.name,
|
||||||
|
color: opts.colour || '#6366f1',
|
||||||
|
password,
|
||||||
|
passwordConfirm: password,
|
||||||
|
famId: opts.famId,
|
||||||
|
role: 'child'
|
||||||
|
});
|
||||||
|
} else if (!user.name || !user.color) {
|
||||||
|
user = await pb.collection('users').update(user.id, {
|
||||||
|
name: user.name || opts.name,
|
||||||
|
color: user.color || opts.colour || '#6366f1'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) throw new Error('Failed to create child');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin grants access to a child: creates the users auth record (or re-issues
|
||||||
|
// OTP if they already exist) + upserts their otp. Returns the OTP and
|
||||||
|
// shareable join link (using the whitespace-free handle) for QR display.
|
||||||
|
export async function issueAccess(opts: {
|
||||||
|
famId: string;
|
||||||
|
famSlug: string;
|
||||||
|
name: string;
|
||||||
|
colour?: string;
|
||||||
|
}) {
|
||||||
|
const { famId, famSlug, name } = opts;
|
||||||
|
const username = handle(name);
|
||||||
|
const otp = generateOtp();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const user = await createChild({ famId, famSlug, name, colour: opts.colour });
|
||||||
|
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
let config = await pb
|
||||||
|
.collection('otp')
|
||||||
|
.getFirstListItem(`famId='${famId}' && userId='${user.id}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
await pb.collection('otp').update(config.id, { otp, updatedAt });
|
||||||
|
} else {
|
||||||
|
await pb.collection('otp').create({ famId, userId: user.id, otp, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child redeems their OTP at /{famSlug}/join/{username}. Verifies the code,
|
||||||
|
// the 20-minute window, and that the account is a child, then authenticates via
|
||||||
|
// authWithPassword and returns a fresh PB JWT. Throws on any failure.
|
||||||
|
export async function redeemOtp(opts: { famSlug: string; username: string; otp: string }) {
|
||||||
|
const { famSlug, username, otp } = opts;
|
||||||
|
const handleName = handle(username); // normalize whatever was in the URL
|
||||||
|
const fullUsername = famUsername(famSlug, handleName);
|
||||||
|
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
|
||||||
|
const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`);
|
||||||
|
if (!fam) throw new Error('Invalid join link');
|
||||||
|
|
||||||
|
let user = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFirstListItem(`famId='${fam.id}' && username='${fullUsername}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!user || user.role !== 'child') throw new Error('Invalid join link');
|
||||||
|
|
||||||
|
let config = await pb
|
||||||
|
.collection('otp')
|
||||||
|
.getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!config || config.otp !== otp) throw new Error('Invalid code');
|
||||||
|
|
||||||
|
const issued = Date.parse(config.updatedAt || '');
|
||||||
|
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
|
||||||
|
|
||||||
|
const authPb = createPbClient();
|
||||||
|
await authPb
|
||||||
|
.collection('users')
|
||||||
|
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
||||||
|
return authPb.authStore.token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { migrate } from './migrate';
|
||||||
|
|
||||||
|
// Runs the PocketBase schema migration once per server process, at boot.
|
||||||
|
// Idempotent (migrate() diffs against existing collections), so re-running on
|
||||||
|
// dev HMR or restarts is a cheap no-op. Errors are logged, not thrown, so a
|
||||||
|
// migration hiccup never takes the server down.
|
||||||
|
let done: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function migrateOnBoot(): Promise<void> {
|
||||||
|
if (!done) {
|
||||||
|
done = migrate().catch((e) => {
|
||||||
|
console.error('[migrate] failed:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { SCHEMA_PLAN } from '@shared/pb/schema';
|
||||||
|
import { PB_ENDPOINT } from '$lib/server/pocketbase';
|
||||||
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
async function auth(): Promise<string> {
|
||||||
|
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<any | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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}`);
|
||||||
|
return data?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCollection(id: string, col: any): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the custom fields + rules the app relies on to PB's native `users`
|
||||||
|
// auth collection (created automatically on first serve). Children live here as
|
||||||
|
// role='child'; username is a password-auth identity so the server can
|
||||||
|
// authWithPassword(derivedPassword) at OTP join time.
|
||||||
|
async function ensureUsers(ids: Record<string, string>): Promise<void> {
|
||||||
|
const usersCol = await getCollection("users");
|
||||||
|
if (!usersCol) throw new Error("users collection not found");
|
||||||
|
const famsId = ids.fams || (await getCollection("fams"))?.id;
|
||||||
|
if (!famsId) throw new Error("fams collection not found");
|
||||||
|
|
||||||
|
const has = (n: string) => usersCol.fields.some((f: any) => f.name === n);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const emailField = usersCol.fields.find((f: any) => f.name === "email");
|
||||||
|
if (emailField && emailField.required) {
|
||||||
|
emailField.required = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!has("famId")) {
|
||||||
|
usersCol.fields.push({
|
||||||
|
name: "famId", type: "relation", required: false,
|
||||||
|
collectionId: famsId, maxSelect: 1, cascadeDelete: false,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!has("role")) {
|
||||||
|
usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!has("username")) {
|
||||||
|
usersCol.fields.push({ name: "username", type: "text", required: true });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!has("color")) {
|
||||||
|
usersCol.fields.push({ name: "color", type: "text", required: false });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let indexes = usersCol.indexes || [];
|
||||||
|
if (!indexes.some((i: string) => /username/i.test(i))) {
|
||||||
|
indexes = [...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] };
|
||||||
|
const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"];
|
||||||
|
if (!identityFields.includes("username")) {
|
||||||
|
identityFields.push("username");
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRule = "famId = @request.auth.famId";
|
||||||
|
const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'";
|
||||||
|
if (usersCol.listRule !== listRule || usersCol.viewRule !== listRule ||
|
||||||
|
usersCol.updateRule !== parentWrite || usersCol.deleteRule !== parentWrite) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await updateCollection(usersCol.id, {
|
||||||
|
name: "users",
|
||||||
|
type: "auth",
|
||||||
|
listRule,
|
||||||
|
viewRule: listRule,
|
||||||
|
createRule: usersCol.createRule || "",
|
||||||
|
updateRule: parentWrite,
|
||||||
|
deleteRule: parentWrite,
|
||||||
|
fields: usersCol.fields,
|
||||||
|
indexes,
|
||||||
|
passwordAuth: { enabled: true, identityFields },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser-only OTP store for the child join gate. Holds the rotating code and
|
||||||
|
// its issue timestamp (20-min window). Not public — read/written via the
|
||||||
|
// superuser client only.
|
||||||
|
async function ensureOtp(ids: Record<string, string>): Promise<void> {
|
||||||
|
if (await getCollection("otp")) return;
|
||||||
|
const famsId = ids.fams || (await getCollection("fams"))?.id;
|
||||||
|
const usersId = ids.users || (await getCollection("users"))?.id;
|
||||||
|
if (!famsId || !usersId) throw new Error("fams/users collection not found");
|
||||||
|
await createCollection({
|
||||||
|
name: "otp",
|
||||||
|
type: "base",
|
||||||
|
listRule: null,
|
||||||
|
viewRule: null,
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
{ name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false },
|
||||||
|
{ name: "userId", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false },
|
||||||
|
{ name: "otp", type: "text", required: false },
|
||||||
|
{ name: "updatedAt", type: "text", required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if
|
||||||
|
// `fams` already exists (data is disposable; there is no incremental migration
|
||||||
|
// history).
|
||||||
|
async function ensureSchema(): Promise<void> {
|
||||||
|
if (await getCollection("fams")) {
|
||||||
|
console.log("[migrate] Schema already present — skipping bootstrap.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[migrate] Bootstrapping schema on fresh PocketBase...");
|
||||||
|
|
||||||
|
const ids: Record<string, string> = {};
|
||||||
|
const nativeUsers = await getCollection("users");
|
||||||
|
if (nativeUsers) ids.users = nativeUsers.id;
|
||||||
|
for (const entry of SCHEMA_PLAN) {
|
||||||
|
const createdId = await createCollection(entry.build(ids));
|
||||||
|
if (createdId) ids[entry.name] = createdId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureUsers(ids);
|
||||||
|
await ensureOtp(ids);
|
||||||
|
console.log("[migrate] Schema bootstrapped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrate(): Promise<void> {
|
||||||
|
console.log("[migrate] Checking PB collection schemas...");
|
||||||
|
await ensureSchema();
|
||||||
|
console.log("[migrate] Done");
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
|
||||||
import { SERVER_IP } from '$app/env/public';
|
|
||||||
export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
|
|
||||||
|
|
||||||
let token: string | null = null;
|
|
||||||
let tokenExpiry = 0;
|
|
||||||
|
|
||||||
async function ensureToken(): Promise<string> {
|
|
||||||
if (token && Date.now() < tokenExpiry) return token;
|
|
||||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`);
|
|
||||||
token = data.token;
|
|
||||||
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
|
|
||||||
return token!;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pbAdmin = {
|
|
||||||
async getList(collection: string, filter = '') {
|
|
||||||
const t = await ensureToken();
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filter) params.set('filter', filter);
|
|
||||||
params.set('perPage', '200');
|
|
||||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
|
|
||||||
headers: { Authorization: `Bearer ${t}` },
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
|
|
||||||
return data.items || [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getOne(collection: string, id: string) {
|
|
||||||
const t = await ensureToken();
|
|
||||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
|
|
||||||
headers: { Authorization: `Bearer ${t}` },
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
|
||||||
const t = await ensureToken();
|
|
||||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
const result = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { SERVER_IP } from '$app/env/public';
|
||||||
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
|
|
||||||
|
export const PB_ENDPOINT = import.meta.env.PROD
|
||||||
|
? 'http://127.0.0.1:8090'
|
||||||
|
: `http://${SERVER_IP}:8090`;
|
||||||
|
|
||||||
|
// Server-side PB client, pre-authenticated as the given user's token.
|
||||||
|
// Used for CRUD as the authenticated user so PB collection rules apply
|
||||||
|
// (famId scoping) instead of running everything as superuser.
|
||||||
|
export function createPbClient(token?: string) {
|
||||||
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
|
if (token) pb.authStore.save(token, null);
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated PB client for the current request's admin/member session.
|
||||||
|
// Requires an active session; redirects to /login otherwise.
|
||||||
|
export function pbUser(event: RequestEvent) {
|
||||||
|
if (!event.locals.user || !event.locals.pbToken) {
|
||||||
|
throw redirect(303, '/login');
|
||||||
|
}
|
||||||
|
return createPbClient(event.locals.pbToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||||
|
// operations that must bypass collection rules: creating child users, minting
|
||||||
|
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
||||||
|
let superClient: PocketBase | null = null;
|
||||||
|
export async function createSuperClient() {
|
||||||
|
if (superClient) return superClient;
|
||||||
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
|
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
||||||
|
superClient = pb;
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
||||||
|
// server PB access (authenticated user + superuser) lives in this one module.
|
||||||
|
export const pbAdmin = {
|
||||||
|
async getList(collection: string, filter = '') {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const options: { filter?: string } = {};
|
||||||
|
if (filter) options.filter = filter;
|
||||||
|
return pb.collection(collection).getFullList(options);
|
||||||
|
},
|
||||||
|
async getOne(collection: string, id: string) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).getOne(id);
|
||||||
|
},
|
||||||
|
async create(collection: string, data: Record<string, unknown>) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).create(data);
|
||||||
|
},
|
||||||
|
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).update(id, data);
|
||||||
|
},
|
||||||
|
async remove(collection: string, id: string) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import {
|
||||||
|
todayInTz,
|
||||||
|
resolveTz,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
nextPaydayAfter,
|
||||||
|
weekStart
|
||||||
|
} from '@shared/timezone';
|
||||||
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
|
function resolveServerTz(tz?: string): string {
|
||||||
|
return resolveTz(tz || 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimableStamp(cfg: any, payday: number, tz: string) {
|
||||||
|
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
|
||||||
|
return { claimable: 'immediate', settleDate: '' };
|
||||||
|
}
|
||||||
|
const now = todayInTz(resolveServerTz(tz));
|
||||||
|
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
|
||||||
|
const end = periodEnd(cfg.period, start);
|
||||||
|
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateFam(pb: any, famId: string) {
|
||||||
|
let configs: any[] = [];
|
||||||
|
try {
|
||||||
|
configs = await pb
|
||||||
|
.collection('bonus_configs')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && status = 'active' && type != 'manual'` });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!configs.length) return;
|
||||||
|
|
||||||
|
const [allMembers, allAssigned, allCompletions] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
]);
|
||||||
|
let allRewards: any[] = [];
|
||||||
|
try {
|
||||||
|
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
} catch {}
|
||||||
|
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId);
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : '';
|
||||||
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
|
||||||
|
const periodCompletions = cfg.period
|
||||||
|
? allCompletions.filter(
|
||||||
|
(c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd
|
||||||
|
)
|
||||||
|
: allCompletions;
|
||||||
|
|
||||||
|
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||||
|
let createdReward = false;
|
||||||
|
|
||||||
|
const rewardData = (memberId: string) => {
|
||||||
|
const label =
|
||||||
|
cfg.rewardType === 'cash'
|
||||||
|
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
|
||||||
|
: `${cfg.name} – ${cfg.rewardValue}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
famId,
|
||||||
|
memberId,
|
||||||
|
bonusConfigId: cfg.id,
|
||||||
|
label,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
rewardType: cfg.rewardType,
|
||||||
|
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||||||
|
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||||||
|
date: now.slice(0, 10),
|
||||||
|
...claimableStamp(cfg, paydayEval, tzEval)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cfg.target === 'individual') {
|
||||||
|
const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers;
|
||||||
|
for (const m of targetMembers) {
|
||||||
|
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
|
let current = 0;
|
||||||
|
if (cfg.type === 'threshold') {
|
||||||
|
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
} else if (cfg.type === 'count') {
|
||||||
|
current = memberCompletions.length;
|
||||||
|
}
|
||||||
|
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
||||||
|
|
||||||
|
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
|
||||||
|
|
||||||
|
if (memberReward && !achieved) {
|
||||||
|
if (memberReward.status !== 'claimed') {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(memberReward.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberReward) continue;
|
||||||
|
|
||||||
|
if (achieved) {
|
||||||
|
await pb.collection('rewards').create(rewardData(m.id));
|
||||||
|
createdReward = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (cfg.target === 'collaborative') {
|
||||||
|
const allMemberIds = allMembers.map((m: any) => m.id);
|
||||||
|
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
||||||
|
let total = 0;
|
||||||
|
if (cfg.type === 'threshold') {
|
||||||
|
total = teamCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
} else if (cfg.type === 'count') {
|
||||||
|
total = teamCompletions.length;
|
||||||
|
}
|
||||||
|
const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
|
||||||
|
|
||||||
|
if (!achieved && existingRewards.length > 0) {
|
||||||
|
for (const r of existingRewards) {
|
||||||
|
if (r.status !== 'claimed') {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(r.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (achieved && existingRewards.length === 0) {
|
||||||
|
for (const m of allMembers) {
|
||||||
|
await pb.collection('rewards').create(rewardData(m.id));
|
||||||
|
createdReward = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (cfg.target === 'competitive') {
|
||||||
|
const scored = allMembers.map((m: any) => {
|
||||||
|
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
|
let current = 0;
|
||||||
|
if (cfg.type === 'threshold') {
|
||||||
|
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
} else if (cfg.type === 'count') {
|
||||||
|
current = memberCompletions.length;
|
||||||
|
}
|
||||||
|
return { memberId: m.id, name: m.name, current };
|
||||||
|
});
|
||||||
|
const qualified = scored.filter((s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue));
|
||||||
|
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
||||||
|
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
||||||
|
|
||||||
|
if (existingRewards.length > 0) {
|
||||||
|
const existing = existingRewards[0];
|
||||||
|
const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0;
|
||||||
|
if (!stillValid && existing.status !== 'claimed') {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(existing.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner && existingRewards.length === 0) {
|
||||||
|
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||||
|
createdReward = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
|
||||||
|
try {
|
||||||
|
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateAll(pb: any, famId: string) {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
return { evaluated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function progress(pb: any, famId: string) {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
let configsData: any[] = [];
|
||||||
|
try {
|
||||||
|
configsData = await pb
|
||||||
|
.collection('bonus_configs')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && status = 'active'` });
|
||||||
|
} catch {}
|
||||||
|
const [members, assigned, completions] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assignedList = assigned;
|
||||||
|
const completionsList = completions;
|
||||||
|
let allRewards: any[] = [];
|
||||||
|
try {
|
||||||
|
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const cfg of configsData) {
|
||||||
|
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||||
|
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
||||||
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
||||||
|
const periodCompletions = cfg.period
|
||||||
|
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
|
||||||
|
: completionsList;
|
||||||
|
|
||||||
|
const progressRows: any[] = [];
|
||||||
|
|
||||||
|
if (cfg.target === 'collaborative') {
|
||||||
|
const teamCompletions = periodCompletions.filter((c: any) =>
|
||||||
|
members.some((m: any) => m.id === c.memberId)
|
||||||
|
);
|
||||||
|
let teamCurrent = 0;
|
||||||
|
if (cfg.type === 'threshold') {
|
||||||
|
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
} else if (cfg.type === 'count') {
|
||||||
|
teamCurrent = teamCompletions.length;
|
||||||
|
}
|
||||||
|
const teamReward = cfgRewards[0];
|
||||||
|
progressRows.push({
|
||||||
|
memberId: '__team__',
|
||||||
|
memberName: 'Team Total',
|
||||||
|
memberColor: '#8b5cf6',
|
||||||
|
current: teamCurrent,
|
||||||
|
criteriaValue: cfg.criteriaValue || 0,
|
||||||
|
reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null,
|
||||||
|
state: teamReward ? teamReward.status : 'pending',
|
||||||
|
achieved: teamReward ? true : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.target !== 'collaborative') {
|
||||||
|
const progressMembers =
|
||||||
|
cfg.target === 'individual' && cfg.memberId
|
||||||
|
? members.filter((m: any) => m.id === cfg.memberId)
|
||||||
|
: members;
|
||||||
|
for (const m of progressMembers) {
|
||||||
|
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
|
const memberReward = cfgRewards.find((r: any) => r.memberId === m.id);
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
if (cfg.type === 'threshold') {
|
||||||
|
current = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
} else if (cfg.type === 'count') {
|
||||||
|
current = memberCompletions.length;
|
||||||
|
} else if (cfg.type === 'manual') {
|
||||||
|
current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressRows.push({
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name,
|
||||||
|
memberColor: m.color,
|
||||||
|
current,
|
||||||
|
criteriaValue: cfg.criteriaValue || 0,
|
||||||
|
reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null,
|
||||||
|
state: memberReward ? memberReward.status : 'pending',
|
||||||
|
achieved: memberReward ? true : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
|
||||||
|
const configs = await pb
|
||||||
|
.collection('bonus_configs')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && id = '${configId}' && status = 'active'` });
|
||||||
|
const cfg = configs?.[0];
|
||||||
|
if (!cfg) throw new Error('Bonus config not found');
|
||||||
|
if (cfg.type !== 'manual') throw new Error('Only manual-type configs can be triggered');
|
||||||
|
|
||||||
|
const existingRewards = await pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && bonusConfigId = '${cfg.id}'`
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && role = 'child'` });
|
||||||
|
|
||||||
|
const targetMembers: any[] = [];
|
||||||
|
if (cfg.target === 'competitive' || cfg.target === 'collaborative') {
|
||||||
|
for (const m of members) targetMembers.push(m);
|
||||||
|
} else if (cfg.target === 'individual') {
|
||||||
|
const targetId = cfg.memberId || memberId;
|
||||||
|
if (!targetId) throw new Error('memberId required for individual trigger');
|
||||||
|
const member = members.find((m: any) => m.id === targetId);
|
||||||
|
if (!member) throw new Error('Member not found');
|
||||||
|
targetMembers.push(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of targetMembers) {
|
||||||
|
const memberRewards = existingRewards.filter((r: any) => r.memberId === m.id);
|
||||||
|
if (cfg.occurrence === 'once' && memberRewards.some((r: any) => r.status === 'unclaimed')) {
|
||||||
|
throw new Error(`Already issued and pending for ${m.name}`);
|
||||||
|
}
|
||||||
|
if (cfg.occurrence === 'recurring' && cfg.period) {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
const pStart = periodStart(cfg.period, payday, tz);
|
||||||
|
const pEnd = periodEnd(cfg.period, pStart);
|
||||||
|
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
|
||||||
|
if (periodRewards.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: any[] = [];
|
||||||
|
for (const m of targetMembers) {
|
||||||
|
const label =
|
||||||
|
cfg.rewardType === 'cash'
|
||||||
|
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
|
||||||
|
: `${cfg.name} – ${cfg.rewardValue}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const record = await pb.collection('rewards').create({
|
||||||
|
famId,
|
||||||
|
memberId: m.id,
|
||||||
|
bonusConfigId: cfg.id,
|
||||||
|
label,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
rewardType: cfg.rewardType,
|
||||||
|
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||||||
|
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||||||
|
date: now.slice(0, 10),
|
||||||
|
claimable: 'immediate',
|
||||||
|
settleDate: ''
|
||||||
|
});
|
||||||
|
created.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.occurrence === 'once') {
|
||||||
|
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggered: true, created: created.length, records: created };
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type ChatActor = {
|
||||||
|
id: string;
|
||||||
|
type: 'admin' | 'member';
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function chatMe(pb: any, famId: string, actor: ChatActor) {
|
||||||
|
return { famId, actor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function send(
|
||||||
|
pb: any,
|
||||||
|
famId: string,
|
||||||
|
actor: ChatActor,
|
||||||
|
body: { content?: string; clientId?: string }
|
||||||
|
) {
|
||||||
|
const content = (body.content || '').trim();
|
||||||
|
if (!content) throw new Error('content required');
|
||||||
|
return pb.collection('messages').create({
|
||||||
|
famId,
|
||||||
|
authorType: actor.type,
|
||||||
|
authorId: actor.id,
|
||||||
|
authorName: actor.name,
|
||||||
|
authorColor: actor.color,
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function typing(
|
||||||
|
pb: any,
|
||||||
|
famId: string,
|
||||||
|
actor: ChatActor,
|
||||||
|
body: { typing?: boolean }
|
||||||
|
) {
|
||||||
|
const existing = await pb.collection('chat_typing').getFullList({
|
||||||
|
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
|
||||||
|
});
|
||||||
|
const row = {
|
||||||
|
famId,
|
||||||
|
actorId: actor.id,
|
||||||
|
actorType: actor.type,
|
||||||
|
authorName: actor.name,
|
||||||
|
authorColor: actor.color,
|
||||||
|
typing: !!body.typing
|
||||||
|
};
|
||||||
|
if (existing?.length) {
|
||||||
|
await pb.collection('chat_typing').update(existing[0].id, row);
|
||||||
|
} else {
|
||||||
|
await pb.collection('chat_typing').create(row);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
|
// Aggregated kanban payload for a single member (child session).
|
||||||
|
export async function myChores(pb: any, famId: string, memberId: string) {
|
||||||
|
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
|
||||||
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
|
]);
|
||||||
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
|
let settings: any = {};
|
||||||
|
try {
|
||||||
|
const s = await pb
|
||||||
|
.collection('settings')
|
||||||
|
.getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
settings = s?.[0] || {};
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
assigned,
|
||||||
|
completions,
|
||||||
|
rewards,
|
||||||
|
bonusConfigs,
|
||||||
|
payday,
|
||||||
|
paydayTime,
|
||||||
|
timezone: tz,
|
||||||
|
simulateEow: !!settings.simulateEow
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { periodWindow } from '@shared/timezone';
|
||||||
|
import { famMeta } from './fam';
|
||||||
|
import { evaluateFam } from './bonuses';
|
||||||
|
|
||||||
|
export async function myChores(pb: any, famId: string, memberId: string) {
|
||||||
|
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
|
||||||
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
|
]);
|
||||||
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
|
let settings: any = {};
|
||||||
|
try {
|
||||||
|
const s = await pb
|
||||||
|
.collection('settings')
|
||||||
|
.getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
settings = s?.[0] || {};
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
assigned,
|
||||||
|
completions,
|
||||||
|
rewards,
|
||||||
|
bonusConfigs,
|
||||||
|
payday,
|
||||||
|
paydayTime,
|
||||||
|
timezone: tz,
|
||||||
|
simulateEow: !!settings.simulateEow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) {
|
||||||
|
const { assignedChoreId, date } = body;
|
||||||
|
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
|
||||||
|
|
||||||
|
const choreList = await pb
|
||||||
|
.collection('assigned_chores')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` });
|
||||||
|
const chore = choreList?.[0];
|
||||||
|
const isTodo = chore?.isTodo;
|
||||||
|
let filter: string;
|
||||||
|
if (isTodo) {
|
||||||
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
|
||||||
|
} else {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
const { from, to } = periodWindow(chore?.frequency, payday, tz);
|
||||||
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
|
||||||
|
}
|
||||||
|
const existing = await pb
|
||||||
|
.collection('completions')
|
||||||
|
.getFullList({ filter });
|
||||||
|
if (existing?.length > 0) {
|
||||||
|
await pb.collection('completions').delete(existing[0].id);
|
||||||
|
evaluateFam(pb, famId).catch(() => {});
|
||||||
|
return { completed: false };
|
||||||
|
}
|
||||||
|
const record = await pb.collection('completions').create({
|
||||||
|
famId,
|
||||||
|
memberId,
|
||||||
|
assignedChoreId,
|
||||||
|
date,
|
||||||
|
completedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
evaluateFam(pb, famId).catch(() => {});
|
||||||
|
return { completed: true, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revoke(pb: any, famId: string, completionId: string) {
|
||||||
|
const completions = await pb
|
||||||
|
.collection('completions')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
|
||||||
|
if (!completions?.length) throw new Error('Completion not found');
|
||||||
|
await pb.collection('completions').delete(completionId);
|
||||||
|
evaluateFam(pb, famId).catch(() => {});
|
||||||
|
return { revoked: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Generic per-resource CRUD, mirroring the old proxy's /api/admin/:famId/<resource>.
|
||||||
|
// Kept generic because many pages (chore templates, bonus templates, members,
|
||||||
|
// assigned-chores, seasons) only need plain list/create/update/delete.
|
||||||
|
|
||||||
|
const RESOURCES: Record<
|
||||||
|
string,
|
||||||
|
{ col: string; listFilter: (famId: string) => string; bonus?: 'config' | 'template' }
|
||||||
|
> = {
|
||||||
|
'chore-templates': { col: 'chore_templates', listFilter: (f) => `famId = '${f}'` },
|
||||||
|
members: { col: 'users', listFilter: (f) => `famId = '${f}' && role = 'child'` },
|
||||||
|
'assigned-chores': { col: 'assigned_chores', listFilter: (f) => `famId = '${f}'` },
|
||||||
|
'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `famId = '${f}'`, bonus: 'template' },
|
||||||
|
'bonus-configs': { col: 'bonus_configs', listFilter: (f) => `famId = '${f}'`, bonus: 'config' },
|
||||||
|
completions: { col: 'completions', listFilter: (f) => `famId = '${f}'` },
|
||||||
|
rewards: { col: 'rewards', listFilter: (f) => `famId = '${f}'` },
|
||||||
|
seasons: { col: 'seasons', listFilter: (f) => `famId = '${f}'` }
|
||||||
|
};
|
||||||
|
|
||||||
|
function res(resource: string) {
|
||||||
|
const r = RESOURCES[resource];
|
||||||
|
if (!r) throw new Error(`Unknown resource: ${resource}`);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bonusBody(c: { bonus?: 'config' | 'template' }, data: Record<string, unknown>) {
|
||||||
|
const body: Record<string, unknown> = { ...data };
|
||||||
|
if (body.occurrence === 'once') body.period = '';
|
||||||
|
if (c.bonus === 'config') body.status = 'active';
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(pb: any, resource: string, famId: string) {
|
||||||
|
const c = res(resource);
|
||||||
|
return pb.collection(c.col).getFullList({ filter: c.listFilter(famId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(
|
||||||
|
pb: any,
|
||||||
|
resource: string,
|
||||||
|
famId: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const c = res(resource);
|
||||||
|
return pb.collection(c.col).create({ famId, ...bonusBody(c, data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(
|
||||||
|
pb: any,
|
||||||
|
resource: string,
|
||||||
|
famId: string,
|
||||||
|
id: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const c = res(resource);
|
||||||
|
return pb.collection(c.col).update(id, bonusBody(c, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(pb: any, resource: string, famId: string, id: string) {
|
||||||
|
const c = res(resource);
|
||||||
|
return pb.collection(c.col).delete(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Dev/test helper (settings debugMode) — random completions for a range of days.
|
||||||
|
|
||||||
|
export async function generateData(pb: any, famId: string, days = 7) {
|
||||||
|
const [members, templates] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!members.length) return { error: 'No members found' };
|
||||||
|
if (!templates.length) return { error: 'No templates found' };
|
||||||
|
|
||||||
|
let completionsCreated = 0;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for (let d = 0; d < days; d++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - d);
|
||||||
|
const dateStr = date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
for (const m of members) {
|
||||||
|
const completionRate = 0.5 + Math.random() * 0.5;
|
||||||
|
for (const t of templates) {
|
||||||
|
if (Math.random() > completionRate) continue;
|
||||||
|
|
||||||
|
let assigned = await pb.collection('assigned_chores').getFullList({
|
||||||
|
filter: `famId = '${famId}' && memberId = '${m.id}' && templateId = '${t.id}'`
|
||||||
|
});
|
||||||
|
let assignedId;
|
||||||
|
if (assigned?.length > 0) {
|
||||||
|
assignedId = assigned[0].id;
|
||||||
|
} else {
|
||||||
|
const record = await pb.collection('assigned_chores').create({
|
||||||
|
famId,
|
||||||
|
memberId: m.id,
|
||||||
|
templateId: t.id,
|
||||||
|
frequency: t.defaultFrequency || 'daily',
|
||||||
|
type: t.defaultType || 'points',
|
||||||
|
value: t.defaultValue || 10
|
||||||
|
});
|
||||||
|
assignedId = record.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await pb.collection('completions').getFullList({
|
||||||
|
filter: `famId = '${famId}' && memberId = '${m.id}' && assignedChoreId = '${assignedId}' && date = '${dateStr}'`
|
||||||
|
});
|
||||||
|
if (existing?.length > 0) continue;
|
||||||
|
|
||||||
|
await pb.collection('completions').create({
|
||||||
|
famId,
|
||||||
|
memberId: m.id,
|
||||||
|
assignedChoreId: assignedId,
|
||||||
|
date: dateStr
|
||||||
|
});
|
||||||
|
completionsCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { completionsCreated, days };
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import {
|
||||||
|
weekStart,
|
||||||
|
addDaysStr,
|
||||||
|
resolveTz,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
wallClockToUtc
|
||||||
|
} from '@shared/timezone';
|
||||||
|
import { slugify } from '@shared/slugify';
|
||||||
|
import { evaluateFam } from './bonuses';
|
||||||
|
|
||||||
|
function resolveServerTz(tz?: string): string {
|
||||||
|
return resolveTz(tz || 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function famMeta(pb: any, famId: string) {
|
||||||
|
const fam = await pb.collection('fams').getOne(famId);
|
||||||
|
return {
|
||||||
|
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
|
||||||
|
paydayTime: fam.paydayTime || '18:00',
|
||||||
|
tz: resolveServerTz(fam.timezone)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFam(pb: any, famId: string) {
|
||||||
|
const fam = await pb.collection('fams').getOne(famId);
|
||||||
|
return {
|
||||||
|
name: fam.name,
|
||||||
|
slug: fam.slug,
|
||||||
|
payday: fam.payday,
|
||||||
|
paydayTime: fam.paydayTime || '18:00',
|
||||||
|
timezone: fam.timezone || 'auto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchFam(pb: any, famId: string, body: Record<string, unknown>) {
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const name = body.name as string;
|
||||||
|
if (!name) throw new Error('name required');
|
||||||
|
const slug = slugify(name);
|
||||||
|
const record = await pb.collection('fams').update(famId, { name, slug });
|
||||||
|
return { name: record.name, slug: record.slug, payday: record.payday };
|
||||||
|
}
|
||||||
|
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
|
||||||
|
const patch: Record<string, string | number> = {};
|
||||||
|
if (body.payday !== undefined) {
|
||||||
|
const payday = Number(body.payday);
|
||||||
|
if (payday < 0 || payday > 6 || !Number.isInteger(payday))
|
||||||
|
throw new Error('payday must be 0-6');
|
||||||
|
patch.payday = payday;
|
||||||
|
}
|
||||||
|
if (body.paydayTime !== undefined) {
|
||||||
|
const paydayTime = String(body.paydayTime);
|
||||||
|
if (!/^\d{2}:\d{2}$/.test(paydayTime)) throw new Error('paydayTime must be HH:MM');
|
||||||
|
patch.paydayTime = paydayTime;
|
||||||
|
}
|
||||||
|
if (body.timezone !== undefined) {
|
||||||
|
const timezone = String(body.timezone);
|
||||||
|
if (timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
|
||||||
|
throw new Error("timezone must be an IANA name or 'auto'");
|
||||||
|
patch.timezone = timezone;
|
||||||
|
}
|
||||||
|
const record = await pb.collection('fams').update(famId, patch);
|
||||||
|
return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone };
|
||||||
|
}
|
||||||
|
throw new Error('no valid fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(pb: any, famId: string, userId: string) {
|
||||||
|
const rec = await pb.collection('users').getOne(userId);
|
||||||
|
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(
|
||||||
|
pb: any,
|
||||||
|
famId: string,
|
||||||
|
userId: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (data.name) patch.name = data.name;
|
||||||
|
if (data.color) patch.color = data.color;
|
||||||
|
if (data.email !== undefined) patch.email = data.email;
|
||||||
|
const rec = await pb.collection('users').update(userId, patch);
|
||||||
|
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function weeklySummary(pb: any, famId: string) {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
|
const [members, assigned, completions] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` })
|
||||||
|
]);
|
||||||
|
|
||||||
|
let rewardPointsList: any[] = [];
|
||||||
|
try {
|
||||||
|
rewardPointsList = await pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const assignedList = assigned;
|
||||||
|
const completionsList = completions;
|
||||||
|
|
||||||
|
const daysInWeek: string[] = [];
|
||||||
|
{
|
||||||
|
const d = new Date(ws + 'T00:00:00Z');
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
daysInWeek.push(d.toISOString().slice(0, 10));
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
members.map(async (m: any) => {
|
||||||
|
const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id);
|
||||||
|
const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id);
|
||||||
|
|
||||||
|
const weekPoints = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const dayPoints: Record<string, number> = {};
|
||||||
|
const dayCompletions: Record<string, number> = {};
|
||||||
|
for (const day of daysInWeek) {
|
||||||
|
dayPoints[day] = 0;
|
||||||
|
dayCompletions[day] = 0;
|
||||||
|
}
|
||||||
|
for (const c of memberCompletions) {
|
||||||
|
const day = (c.date || '').slice(0, 10);
|
||||||
|
if (dayPoints[day] !== undefined) {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
dayPoints[day] += chore?.type === 'points' ? Number(chore.value) : 0;
|
||||||
|
dayCompletions[day]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonusPoints = rewardPointsList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
|
||||||
|
const weekMoney = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
let bonusMoney = 0;
|
||||||
|
try {
|
||||||
|
const cashRewards = await pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
||||||
|
});
|
||||||
|
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name,
|
||||||
|
memberColor: m.color,
|
||||||
|
pointsEarned: weekPoints + bonusPoints,
|
||||||
|
moneyEarned: weekMoney + bonusMoney,
|
||||||
|
choresCompleted: memberCompletions.length,
|
||||||
|
totalChores: memberAssignments.length,
|
||||||
|
dayPoints,
|
||||||
|
dayCompletions
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { weekStart: ws, daysInWeek, summaries };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eowPreview(pb: any, famId: string) {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
|
const we = periodEnd('weekly', ws);
|
||||||
|
|
||||||
|
const [members, assigned, completions, configs, rewards] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }),
|
||||||
|
pb
|
||||||
|
.collection('bonus_configs')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
|
.catch(() => []),
|
||||||
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
let rewardPointsList: any[] = [];
|
||||||
|
let rewardCashList: any[] = [];
|
||||||
|
try {
|
||||||
|
[rewardPointsList, rewardCashList] = await Promise.all([
|
||||||
|
pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
||||||
|
}),
|
||||||
|
pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const assignedList = assigned;
|
||||||
|
const completionsList = completions;
|
||||||
|
|
||||||
|
const summaries = members.map((m: any) => {
|
||||||
|
const mc = completionsList.filter((c: any) => c.memberId === m.id);
|
||||||
|
const weekPoints = mc.reduce((sum: number, c: any) => {
|
||||||
|
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
const weekMoney = mc.reduce((sum: number, c: any) => {
|
||||||
|
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (ch?.type === 'money' ? Number(ch.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
const bonusPoints = rewardPointsList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
const bonusMoney = rewardCashList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
return {
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name || m.username || m.id.slice(0, 6),
|
||||||
|
memberColor: m.color,
|
||||||
|
pointsEarned: weekPoints + bonusPoints,
|
||||||
|
moneyEarned: weekMoney + bonusMoney,
|
||||||
|
choresCompleted: mc.length,
|
||||||
|
bonusEarned: bonusPoints
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const predictedRewards: any[] = [];
|
||||||
|
const existingRewards = rewards;
|
||||||
|
for (const cfg of configs) {
|
||||||
|
if (cfg.type === 'manual') continue;
|
||||||
|
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
||||||
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
||||||
|
const periodCompletions = cfg.period
|
||||||
|
? completionsList.filter(
|
||||||
|
(c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd
|
||||||
|
)
|
||||||
|
: completionsList;
|
||||||
|
const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||||
|
|
||||||
|
const tryEval = (sourceComps: any[]) => {
|
||||||
|
if (cfg.type === 'threshold')
|
||||||
|
return sourceComps.reduce((sum: number, c: any) => {
|
||||||
|
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
if (cfg.type === 'count') return sourceComps.length;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cfg.target === 'individual') {
|
||||||
|
const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members;
|
||||||
|
for (const m of targets) {
|
||||||
|
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
||||||
|
const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id));
|
||||||
|
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||||
|
predictedRewards.push({
|
||||||
|
config: cfg.name,
|
||||||
|
memberName: m.name || m.id.slice(0, 6),
|
||||||
|
type: cfg.rewardType,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (cfg.target === 'collaborative') {
|
||||||
|
if (cfgRewards.length) continue;
|
||||||
|
const allIds = members.map((m: any) => m.id);
|
||||||
|
const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId));
|
||||||
|
const current = tryEval(teamComps);
|
||||||
|
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||||
|
predictedRewards.push({
|
||||||
|
config: cfg.name,
|
||||||
|
memberName: 'Everyone',
|
||||||
|
type: cfg.rewardType,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
||||||
|
});
|
||||||
|
} else if (cfg.target === 'competitive') {
|
||||||
|
if (cfgRewards.length) continue;
|
||||||
|
const scored = members.map((m: any) => ({
|
||||||
|
memberId: m.id,
|
||||||
|
name: m.name,
|
||||||
|
current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id))
|
||||||
|
}));
|
||||||
|
const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue));
|
||||||
|
const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0);
|
||||||
|
const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0];
|
||||||
|
if (winner)
|
||||||
|
predictedRewards.push({
|
||||||
|
config: cfg.name,
|
||||||
|
memberName: winner.name,
|
||||||
|
type: cfg.rewardType,
|
||||||
|
value: Number(cfg.rewardValue) || 0,
|
||||||
|
detail: `winner ${winner.current} pts`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWeekStart = addDaysStr(ws, 7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
simulateEow: true,
|
||||||
|
weekStart: ws,
|
||||||
|
weekEnd: we,
|
||||||
|
nextWeekStart,
|
||||||
|
summaries,
|
||||||
|
predictedRewards,
|
||||||
|
completionsThisWeek: completionsList.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseWeek(pb: any, famId: string) {
|
||||||
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
|
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
|
||||||
|
const fam = fams?.[0];
|
||||||
|
if (!fam) throw new Error('Fam not found');
|
||||||
|
|
||||||
|
const wsToday = weekStart(payday, tz);
|
||||||
|
const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz));
|
||||||
|
if (Date.now() < target.getTime()) {
|
||||||
|
return {
|
||||||
|
settled: false,
|
||||||
|
notYet: true,
|
||||||
|
weekStart: wsToday,
|
||||||
|
target: target.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
||||||
|
|
||||||
|
const members = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && role = 'child'` });
|
||||||
|
let cashRewards: any[] = [];
|
||||||
|
try {
|
||||||
|
cashRewards = await pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const breakdown: any[] = [];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const today = now.slice(0, 10);
|
||||||
|
|
||||||
|
for (const m of members) {
|
||||||
|
const unpaid = cashRewards.filter((r: any) => r.memberId === m.id && r.status !== 'claimed');
|
||||||
|
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
for (const r of unpaid) {
|
||||||
|
if (r.status !== 'requested') {
|
||||||
|
await pb.collection('rewards').update(r.id, {
|
||||||
|
status: 'requested',
|
||||||
|
claimedAt: null,
|
||||||
|
date: (r.date || '').slice(0, 10) || today
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
breakdown.push({
|
||||||
|
memberId: m.id,
|
||||||
|
name: m.name,
|
||||||
|
total,
|
||||||
|
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pb.collection('fams').update(famId, { lastIssued: wsToday });
|
||||||
|
return { settled: true, weekStart: wsToday, breakdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeWeek(pb: any, famId: string) {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
|
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
|
||||||
|
const [members, assigned, completions] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` })
|
||||||
|
]);
|
||||||
|
|
||||||
|
let rewardPointsList: any[] = [];
|
||||||
|
let rewardCashList: any[] = [];
|
||||||
|
try {
|
||||||
|
[rewardPointsList, rewardCashList] = await Promise.all([
|
||||||
|
pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
||||||
|
}),
|
||||||
|
pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const assignedList = assigned;
|
||||||
|
const historyRecords: any[] = [];
|
||||||
|
|
||||||
|
for (const m of members) {
|
||||||
|
const memberCompletions = completions.filter((c: any) => c.memberId === m.id);
|
||||||
|
|
||||||
|
const weekPoints = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const weekMoney = memberCompletions.reduce((sum: number, c: any) => {
|
||||||
|
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const bonusPoints = rewardPointsList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
|
||||||
|
const bonusMoney = rewardCashList
|
||||||
|
.filter((r: any) => r.memberId === m.id)
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
|
|
||||||
|
const existing = await pb
|
||||||
|
.collection('weekly_history')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && memberId = '${m.id}' && weekStart = '${ws}'` });
|
||||||
|
const recordData = {
|
||||||
|
famId,
|
||||||
|
memberId: m.id,
|
||||||
|
weekStart: ws,
|
||||||
|
pointsEarned: weekPoints + bonusPoints,
|
||||||
|
moneyEarned: weekMoney + bonusMoney,
|
||||||
|
choresCompleted: memberCompletions.length,
|
||||||
|
bonusEarned: bonusPoints
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await pb.collection('weekly_history').update(existing[0].id, recordData);
|
||||||
|
} else {
|
||||||
|
const record = await pb.collection('weekly_history').create(recordData);
|
||||||
|
historyRecords.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekStart: ws,
|
||||||
|
historyRecords,
|
||||||
|
memberCount: members.length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import * as famSvc from './fam';
|
||||||
|
import * as choresSvc from './chores';
|
||||||
|
import * as completionsSvc from './completions';
|
||||||
|
import * as rewardsSvc from './rewards';
|
||||||
|
import * as bonusesSvc from './bonuses';
|
||||||
|
import * as chatSvc from './chat';
|
||||||
|
import * as settingsSvc from './settings';
|
||||||
|
import * as crudSvc from './crud';
|
||||||
|
import * as debugSvc from './debug';
|
||||||
|
|
||||||
|
export * from './chat';
|
||||||
|
export { famMeta } from './fam';
|
||||||
|
export { assertPaydayUnlocked } from './rewards';
|
||||||
|
|
||||||
|
// Per-feature service binder. `pb` is the acting user's own PocketBase client
|
||||||
|
// (child or parent token), so PB collection rules enforce famId + role scoping;
|
||||||
|
// this layer is purely in-process logic grouped by app area for readability.
|
||||||
|
// `user` supplies the acting user's id/role so member-scoped ops default to it.
|
||||||
|
export function createServices(
|
||||||
|
pb: any,
|
||||||
|
user?: { id?: string; role?: string; name?: string; color?: string }
|
||||||
|
) {
|
||||||
|
const uid = user?.id || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
fam: {
|
||||||
|
meta: (famId: string) => famSvc.famMeta(pb, famId),
|
||||||
|
get: (famId: string) => famSvc.getFam(pb, famId),
|
||||||
|
update: (famId: string, body: Record<string, unknown>) => famSvc.patchFam(pb, famId, body),
|
||||||
|
rename: (famId: string, name: string) => famSvc.patchFam(pb, famId, { name }),
|
||||||
|
updatePayday: (famId: string, payday: number, paydayTime?: string, timezone?: string) =>
|
||||||
|
famSvc.patchFam(pb, famId, {
|
||||||
|
payday,
|
||||||
|
...(paydayTime !== undefined ? { paydayTime } : {}),
|
||||||
|
...(timezone !== undefined ? { timezone } : {})
|
||||||
|
}),
|
||||||
|
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
|
||||||
|
eowPreview: (famId: string) => famSvc.eowPreview(pb, famId),
|
||||||
|
payday: (famId: string) => famSvc.releaseWeek(pb, famId),
|
||||||
|
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
|
||||||
|
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
|
||||||
|
updateProfile: (famId: string, data: Record<string, unknown>) =>
|
||||||
|
famSvc.updateProfile(pb, famId, uid, data)
|
||||||
|
},
|
||||||
|
crud: {
|
||||||
|
list: (resource: string, famId: string) => crudSvc.list(pb, resource, famId),
|
||||||
|
create: (resource: string, famId: string, data: Record<string, unknown>) =>
|
||||||
|
crudSvc.create(pb, resource, famId, data),
|
||||||
|
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
|
||||||
|
crudSvc.update(pb, resource, famId, id, data),
|
||||||
|
remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id)
|
||||||
|
},
|
||||||
|
chores: {
|
||||||
|
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid)
|
||||||
|
},
|
||||||
|
completions: {
|
||||||
|
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
|
||||||
|
completionsSvc.toggle(pb, famId, uid, body),
|
||||||
|
revoke: (famId: string, completionId: string) => completionsSvc.revoke(pb, famId, completionId)
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
|
||||||
|
approve: (famId: string, id: string) => rewardsSvc.approve(pb, famId, id),
|
||||||
|
requestAll: (famId: string) => rewardsSvc.requestAll(pb, famId, uid),
|
||||||
|
issueAll: (famId: string, memberId: string) => rewardsSvc.issueAll(pb, famId, memberId)
|
||||||
|
},
|
||||||
|
bonuses: {
|
||||||
|
progress: (famId: string) => bonusesSvc.progress(pb, famId),
|
||||||
|
evaluate: (famId: string) => bonusesSvc.evaluateAll(pb, famId),
|
||||||
|
trigger: (famId: string, configId: string, memberId?: string) =>
|
||||||
|
bonusesSvc.trigger(pb, famId, configId, memberId),
|
||||||
|
assign: (famId: string, configId: string, data: Record<string, unknown>) => {
|
||||||
|
const updates: Record<string, unknown> = { status: 'active' };
|
||||||
|
if (data.target) updates.target = data.target;
|
||||||
|
if (data.memberId !== undefined) updates.memberId = data.memberId || null;
|
||||||
|
return pb.collection('bonus_configs').update(configId, updates);
|
||||||
|
},
|
||||||
|
complete: (famId: string, configId: string) =>
|
||||||
|
pb.collection('bonus_configs').update(configId, { status: 'completed' }),
|
||||||
|
destroy: (famId: string, configId: string) =>
|
||||||
|
pb.collection('bonus_configs').delete(configId)
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
get: (famId: string) => settingsSvc.getSettings(pb, famId),
|
||||||
|
update: (famId: string, data: Record<string, unknown>) =>
|
||||||
|
settingsSvc.updateSettings(pb, famId, data)
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
|
||||||
|
send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) =>
|
||||||
|
chatSvc.send(pb, famId, actor, body),
|
||||||
|
typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) =>
|
||||||
|
chatSvc.typing(pb, famId, actor, body)
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Services = ReturnType<typeof createServices>;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { todayInTz, resolveTz } from '@shared/timezone';
|
||||||
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
|
function resolveServerTz(tz?: string): string {
|
||||||
|
return resolveTz(tz || 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPaydayUnlocked(reward: any, tz?: string) {
|
||||||
|
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
|
||||||
|
const today = todayInTz(resolveServerTz(tz));
|
||||||
|
if (today < reward.settleDate) {
|
||||||
|
throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member claim → status 'requested' (pending parent approval).
|
||||||
|
export async function claim(pb: any, famId: string, id: string) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const { tz } = await famMeta(pb, famId);
|
||||||
|
const found = await pb
|
||||||
|
.collection('rewards')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
|
||||||
|
const reward = found?.[0];
|
||||||
|
if (!reward) throw new Error('Reward not found');
|
||||||
|
assertPaydayUnlocked(reward, tz);
|
||||||
|
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin approval → status 'claimed'.
|
||||||
|
export async function approve(pb: any, famId: string, id: string) {
|
||||||
|
return pb.collection('rewards').update(id, {
|
||||||
|
status: 'claimed',
|
||||||
|
claimedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestAll(pb: any, famId: string, memberId: string) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const { tz } = await famMeta(pb, famId);
|
||||||
|
const rewards = await pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
|
||||||
|
});
|
||||||
|
let count = 0;
|
||||||
|
for (const r of rewards) {
|
||||||
|
try {
|
||||||
|
assertPaydayUnlocked(r, tz);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await pb.collection('rewards').update(r.id, { status: 'requested', requestedAt: now });
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueAll(pb: any, famId: string, memberId: string) {
|
||||||
|
if (!memberId) throw new Error('memberId required');
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const rewards = await pb.collection('rewards').getFullList({
|
||||||
|
filter: `famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`
|
||||||
|
});
|
||||||
|
let count = 0;
|
||||||
|
for (const r of rewards) {
|
||||||
|
await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now });
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { todayInTz, resolveTz } from '@shared/timezone';
|
||||||
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
|
function resolveServerTz(tz?: string): string {
|
||||||
|
return resolveTz(tz || 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings(pb: any, famId: string) {
|
||||||
|
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
const s = list?.[0] || {};
|
||||||
|
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
|
||||||
|
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
const existing = list?.[0];
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (data.simulateEow !== undefined) patch.simulateEow = !!data.simulateEow;
|
||||||
|
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
|
||||||
|
let s;
|
||||||
|
if (existing) {
|
||||||
|
s = Object.keys(patch).length ? await pb.collection('settings').update(existing.id, patch) : existing;
|
||||||
|
} else {
|
||||||
|
s = await pb.collection('settings').create({ famId, ...patch });
|
||||||
|
}
|
||||||
|
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { pbUser } from '$lib/server/pocketbase';
|
||||||
|
import { createServices, type Services } from '$lib/server/services';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// Build the per-feature service binder for a server request, running as the
|
||||||
|
// authenticated user's own PB client (session cookie). Shorthand for the
|
||||||
|
// common `createServices(pbUser(event), event.locals.user)` call used in loads
|
||||||
|
// and form actions.
|
||||||
|
export function servicesFor(event: RequestEvent): Services {
|
||||||
|
return createServices(pbUser(event), event.locals.user || undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// The PocketBase JWT lives in a single cookie shared by:
|
||||||
|
// - the server hooks (authRefresh -> locals.user)
|
||||||
|
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
|
||||||
|
// httpOnly keeps the token out of reach of browser JS/XSS; the client receives
|
||||||
|
// the token server-side via the layout load (pbToken) and seeds pb.authStore.
|
||||||
|
// Secure flag is set in prod so it's only sent over HTTPS.
|
||||||
|
export const SESSION_COOKIE = 'pb_token';
|
||||||
|
const MAX_AGE = 60 * 60 * 24; // 24h, PB token exp is the real ceiling
|
||||||
|
|
||||||
|
export function setSessionCookie(cookies: Cookies, token: string) {
|
||||||
|
cookies.set(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: MAX_AGE,
|
||||||
|
secure: import.meta.env.PROD
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(cookies: Cookies) {
|
||||||
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username?: string;
|
||||||
|
role: 'parent' | 'child';
|
||||||
|
famId: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
@@ -7,8 +7,18 @@ interface ChatInit {
|
|||||||
actorType: 'admin' | 'member';
|
actorType: 'admin' | 'member';
|
||||||
actorName: string;
|
actorName: string;
|
||||||
actorColor: string;
|
actorColor: string;
|
||||||
deviceToken?: string;
|
pbToken?: string;
|
||||||
memberId?: string;
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
|
||||||
|
// secure context (HTTPS/localhost) — over plain HTTP on a LAN it's undefined,
|
||||||
|
// so fall back to a time+random string that's still unique enough per session.
|
||||||
|
function genClientId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatStore {
|
class ChatStore {
|
||||||
@@ -23,8 +33,7 @@ class ChatStore {
|
|||||||
actorType = $state<'admin' | 'member'>('member');
|
actorType = $state<'admin' | 'member'>('member');
|
||||||
actorName = $state('');
|
actorName = $state('');
|
||||||
actorColor = $state('');
|
actorColor = $state('');
|
||||||
deviceToken = $state('');
|
pbToken = $state('');
|
||||||
memberId = $state('');
|
|
||||||
|
|
||||||
private unsubs: (() => void)[] = [];
|
private unsubs: (() => void)[] = [];
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
@@ -46,8 +55,7 @@ class ChatStore {
|
|||||||
this.actorType = opts.actorType;
|
this.actorType = opts.actorType;
|
||||||
this.actorName = opts.actorName;
|
this.actorName = opts.actorName;
|
||||||
this.actorColor = opts.actorColor;
|
this.actorColor = opts.actorColor;
|
||||||
this.deviceToken = opts.deviceToken || '';
|
this.pbToken = opts.pbToken || '';
|
||||||
this.memberId = opts.memberId || '';
|
|
||||||
|
|
||||||
if (this.initialized && this.famId === opts.famId) return;
|
if (this.initialized && this.famId === opts.famId) return;
|
||||||
if (this.initPromise) {
|
if (this.initPromise) {
|
||||||
@@ -172,7 +180,7 @@ class ChatStore {
|
|||||||
// ── Writes via SvelteKit server (forwards session/device auth) ──
|
// ── Writes via SvelteKit server (forwards session/device auth) ──
|
||||||
|
|
||||||
private async serverChat(payload: Record<string, unknown>) {
|
private async serverChat(payload: Record<string, unknown>) {
|
||||||
const res = await fetch('/chat', {
|
const res = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
@@ -185,7 +193,7 @@ class ChatStore {
|
|||||||
async send(content: string) {
|
async send(content: string) {
|
||||||
const text = content.trim();
|
const text = content.trim();
|
||||||
if (!text || !this.famId) return;
|
if (!text || !this.famId) return;
|
||||||
const clientId = crypto.randomUUID();
|
const clientId = genClientId();
|
||||||
const temp: ChatMessage = {
|
const temp: ChatMessage = {
|
||||||
id: 'temp-' + clientId,
|
id: 'temp-' + clientId,
|
||||||
famId: this.famId,
|
famId: this.famId,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
type CollectionName =
|
type CollectionName =
|
||||||
| 'members'
|
| 'users'
|
||||||
| 'chore_templates'
|
| 'chore_templates'
|
||||||
| 'assigned_chores'
|
| 'assigned_chores'
|
||||||
| 'completions'
|
| 'completions'
|
||||||
@@ -96,9 +96,9 @@ class FamStore {
|
|||||||
rewardsRes,
|
rewardsRes,
|
||||||
seasonsRes
|
seasonsRes
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
|
pb.collection('users').getFullList({
|
||||||
Member[]
|
filter: `famId = '${famId}' && role = 'child'`
|
||||||
>,
|
}) as Promise<Member[]>,
|
||||||
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
|
||||||
ChoreTemplate[]
|
ChoreTemplate[]
|
||||||
>,
|
>,
|
||||||
@@ -144,19 +144,18 @@ class FamStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async subscribe() {
|
private async subscribe() {
|
||||||
const subs: { collection: CollectionName; filter?: string }[] = [
|
const subs: { collection: CollectionName; filter: string }[] = [
|
||||||
{ collection: 'members', filter: this.famId },
|
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
|
||||||
{ collection: 'chore_templates', filter: this.famId },
|
{ collection: 'chore_templates', filter: `famId = '${this.famId}'` },
|
||||||
{ collection: 'assigned_chores', filter: this.famId },
|
{ collection: 'assigned_chores', filter: `famId = '${this.famId}'` },
|
||||||
{ collection: 'completions', filter: this.famId },
|
{ collection: 'completions', filter: `famId = '${this.famId}'` },
|
||||||
{ collection: 'bonus_configs', filter: this.famId },
|
{ collection: 'bonus_configs', filter: `famId = '${this.famId}'` },
|
||||||
{ collection: 'bonus_templates', filter: this.famId },
|
{ collection: 'bonus_templates', filter: `famId = '${this.famId}'` },
|
||||||
{ collection: 'rewards', filter: this.famId },
|
{ collection: 'rewards', filter: `famId = '${this.famId}'` },
|
||||||
{ collection: 'seasons', filter: this.famId }
|
{ collection: 'seasons', filter: `famId = '${this.famId}'` }
|
||||||
];
|
];
|
||||||
|
|
||||||
const promises = subs.map(({ collection, filter }) => {
|
const promises = subs.map(({ collection, filter }) => {
|
||||||
const filterStr = filter ? `famId = '${filter}'` : '';
|
|
||||||
return pb
|
return pb
|
||||||
.collection(collection)
|
.collection(collection)
|
||||||
.subscribe(
|
.subscribe(
|
||||||
@@ -165,7 +164,7 @@ class FamStore {
|
|||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.handleRealtime(collection, data.action, data.record);
|
this.handleRealtime(collection, data.action, data.record);
|
||||||
},
|
},
|
||||||
{ filter: filterStr || undefined }
|
{ filter: filter || undefined }
|
||||||
)
|
)
|
||||||
.then((unsub) => {
|
.then((unsub) => {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
@@ -194,7 +193,7 @@ class FamStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
switch (collection) {
|
switch (collection) {
|
||||||
case 'members':
|
case 'users':
|
||||||
this.members = apply(this.members);
|
this.members = apply(this.members);
|
||||||
break;
|
break;
|
||||||
case 'chore_templates':
|
case 'chore_templates':
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export interface Fam {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
inviteCode: string;
|
|
||||||
stripeCustomerId?: string;
|
stripeCustomerId?: string;
|
||||||
featureFlags: Record<string, boolean>;
|
featureFlags: Record<string, boolean>;
|
||||||
payday?: number;
|
payday?: number;
|
||||||
@@ -54,10 +53,10 @@ export interface Fam {
|
|||||||
export interface Member {
|
export interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
|
username: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
deviceToken: string;
|
role: 'child';
|
||||||
deviceTokenHint: string;
|
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
|
||||||
|
import type { SessionUser } from '$lib/server/types';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login', '/signup', '/pair'];
|
||||||
|
const ADMIN_ONLY_PREFIXES = ['/admin'];
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
event.locals.user = null;
|
||||||
|
event.locals.pbToken = null;
|
||||||
|
|
||||||
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const pb = createPbClient(token);
|
||||||
|
try {
|
||||||
|
// authRefresh() does two jobs in one call:
|
||||||
|
// 1. Verifies the token. PocketBase JWTs can't be checked
|
||||||
|
// offline (the signing secret is per-record and never
|
||||||
|
// leaves PB), so this round trip IS the verification step.
|
||||||
|
// 2. Returns the current record — which is the only way to get
|
||||||
|
// username/role/famId, since PB deliberately doesn't embed
|
||||||
|
// custom fields in the token itself.
|
||||||
|
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
||||||
|
|
||||||
|
event.locals.user = {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
role: record.role,
|
||||||
|
famId: record.famId
|
||||||
|
} satisfies SessionUser;
|
||||||
|
event.locals.pbToken = freshToken;
|
||||||
|
|
||||||
|
if (freshToken !== token) {
|
||||||
|
setSessionCookie(event.cookies, freshToken);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Expired, malformed, or revoked (password/deviceToken changed
|
||||||
|
// since this token was issued) — drop it and treat as logged out.
|
||||||
|
clearSessionCookie(event.cookies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = event.url.pathname;
|
||||||
|
const isPublic = PUBLIC_PATHS.some((p) => path.startsWith(p));
|
||||||
|
|
||||||
|
if (!isPublic && !event.locals.user) {
|
||||||
|
throw redirect(303, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ADMIN_ONLY_PREFIXES.some((p) => path.startsWith(p)) && !event.locals.user) {
|
||||||
|
console.log("rejecting",event.locals.user);
|
||||||
|
throw redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -1,25 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
import type { Action, SubmitFunction } from './$types';
|
|
||||||
|
|
||||||
let email = $state('');
|
|
||||||
let password = $state('');
|
|
||||||
let famName = $state('');
|
|
||||||
let parentName = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
let submitting = $state(false);
|
|
||||||
|
|
||||||
const submit: SubmitFunction = () => {
|
|
||||||
error = '';
|
|
||||||
submitting = true;
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'failure') {
|
|
||||||
error = (result.data as any)?.error || 'Something went wrong. Please try again.';
|
|
||||||
}
|
|
||||||
submitting = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -52,37 +32,7 @@
|
|||||||
<div class="signup-card">
|
<div class="signup-card">
|
||||||
<h2>Start your family</h2>
|
<h2>Start your family</h2>
|
||||||
<p class="card-sub">Free to get going. Takes about a minute.</p>
|
<p class="card-sub">Free to get going. Takes about a minute.</p>
|
||||||
<form method="POST" action="/signup" use:enhance={submit}>
|
<a class="submit" href="/signup">Create my family</a>
|
||||||
{#if error}
|
|
||||||
<p class="form-error">{error}</p>
|
|
||||||
{/if}
|
|
||||||
<label>
|
|
||||||
Family name
|
|
||||||
<input name="famName" bind:value={famName} placeholder="The Smiths" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Your name
|
|
||||||
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
bind:value={password}
|
|
||||||
placeholder="8+ characters"
|
|
||||||
minlength={8}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button class="submit" type="submit" disabled={submitting}>
|
|
||||||
{submitting ? 'Creating your family…' : 'Create my family'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p class="card-alt">
|
<p class="card-alt">
|
||||||
Already have a family? <a href="/login">Log in</a>
|
Already have a family? <a href="/login">Log in</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -247,17 +197,20 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.submit {
|
.submit {
|
||||||
margin-top: 0.25rem;
|
display: block;
|
||||||
background: #4338ca;
|
text-align: center;
|
||||||
color: #fff;
|
margin-top: 0.25rem;
|
||||||
border: none;
|
background: #4338ca;
|
||||||
border-radius: 8px;
|
color: #fff;
|
||||||
padding: 0.75rem;
|
text-decoration: none;
|
||||||
font-size: 1rem;
|
border: none;
|
||||||
font-weight: 600;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
padding: 0.75rem;
|
||||||
}
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.submit:hover { background: #3730a3; }
|
.submit:hover { background: #3730a3; }
|
||||||
.submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
.submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
.card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; }
|
.card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { redeemOtp } from '$lib/server/member-otp';
|
||||||
|
import { setSessionCookie } from '$lib/server/session';
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async (event) => {
|
||||||
|
const famSlug = event.params.famSlug;
|
||||||
|
const username = event.params.username;
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const otp = (fd.get('otp') || '').toString().trim();
|
||||||
|
|
||||||
|
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await redeemOtp({ famSlug, username, otp });
|
||||||
|
event.cookies.delete('device_token', { path: '/' });
|
||||||
|
setSessionCookie(event.cookies, token);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect(303, `/${famSlug}/${encodeURIComponent(username)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { Button } from '$lib/components';
|
||||||
|
|
||||||
|
const famSlug = page.params.famSlug;
|
||||||
|
const username = page.params.username;
|
||||||
|
let otp = $state(page.url.searchParams.get('code') || '');
|
||||||
|
let { form } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Join {famSlug}</title></svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||||
|
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
||||||
|
🏠
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
|
||||||
|
gave you to get started.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="mt-6 flex flex-col gap-3" method="POST" use:enhance={() => ({})}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="otp"
|
||||||
|
bind:value={otp}
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||||
|
/>
|
||||||
|
{#if form?.error}
|
||||||
|
<p class="text-sm font-medium text-rose-600">{form.error}</p>
|
||||||
|
{/if}
|
||||||
|
<Button type="submit" variant="primary" size="lg">Join</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-xs text-slate-400">
|
||||||
|
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
@@ -1,93 +1,79 @@
|
|||||||
import { PROXY_URL } from '$app/env/public';
|
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
|
||||||
import { pbAdmin } from '$lib/server/pb-admin';
|
import { createServices, type ChatActor } from '$lib/server/services';
|
||||||
|
|
||||||
const HONO_URL = PROXY_URL;
|
async function paydayCheck(famId: string, pbToken: string) {
|
||||||
|
|
||||||
async function paydayCheck(famId: string, headers: Record<string, string>) {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${HONO_URL}/api/fam/${famId}/payday`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Best-effort: never block render on the payday heartbeat.
|
// Best-effort: never block render on the payday heartbeat.
|
||||||
await res.json().catch(() => null);
|
const s = createServices(createPbClient(pbToken));
|
||||||
|
await s.fam.payday(famId);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveChatIdentity(
|
function actorFrom(session: {
|
||||||
api: 'admin' | 'member',
|
famId: string;
|
||||||
opts: {
|
id: string;
|
||||||
session?: { famId: string; userId: string };
|
role: string;
|
||||||
deviceToken?: string;
|
name?: string;
|
||||||
famId?: string;
|
color?: string;
|
||||||
}
|
}): ChatActor {
|
||||||
) {
|
return {
|
||||||
|
id: session.id,
|
||||||
|
type: session.role === 'parent' ? 'admin' : 'member',
|
||||||
|
name: session.name || '',
|
||||||
|
color: session.color || '#6366f1'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveChatIdentity(session: {
|
||||||
|
famId: string;
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
}, pbToken: string) {
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const s = createServices(createPbClient(pbToken));
|
||||||
if (api === 'admin' && opts.session) {
|
const actor = actorFrom(session);
|
||||||
headers['x-session-famid'] = opts.session.famId;
|
return await s.chat.me(session.famId, actor);
|
||||||
headers['x-session-userid'] = opts.session.userId;
|
|
||||||
} else if (api === 'member' && opts.deviceToken && opts.famId) {
|
|
||||||
headers['x-device-token'] = opts.deviceToken;
|
|
||||||
headers['x-device-famid'] = opts.famId;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const res = await fetch(`${HONO_URL}/api/chat/me`, { headers });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return await res.json();
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session || null;
|
const session = event.locals.user;
|
||||||
const isParent = session !== null;
|
const role = session?.role || 'child';
|
||||||
const deviceToken = event.cookies.get('device_token') || '';
|
const isParent = role === 'parent';
|
||||||
|
const pbToken = event.cookies.get('pb_token') || '';
|
||||||
|
|
||||||
let famId = '';
|
let famId = '';
|
||||||
let chat: { famId: string; actor: any } | null = null;
|
let chat: { famId: string; actor: ChatActor } | null = null;
|
||||||
|
|
||||||
if (isParent) {
|
if (session && pbToken) {
|
||||||
famId = session.famId;
|
famId = session.famId;
|
||||||
await paydayCheck(famId, {
|
await paydayCheck(famId, pbToken);
|
||||||
'x-session-famid': session.famId,
|
chat = await resolveChatIdentity(session, pbToken);
|
||||||
'x-session-userid': session.userId
|
|
||||||
});
|
|
||||||
chat = await resolveChatIdentity('admin', { session });
|
|
||||||
} else if (deviceToken) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
|
||||||
headers: { 'x-device-token': deviceToken }
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const body = await res.json();
|
|
||||||
famId = body.famId || '';
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
if (famId) {
|
|
||||||
await paydayCheck(famId, {
|
|
||||||
'x-device-token': deviceToken,
|
|
||||||
'x-device-famid': famId
|
|
||||||
});
|
|
||||||
chat = await resolveChatIdentity('member', { deviceToken, famId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session: session
|
||||||
|
? {
|
||||||
|
famId: session.famId,
|
||||||
|
userId: session.id,
|
||||||
|
famSlug: event.params.fam,
|
||||||
|
memberName: session.name,
|
||||||
|
memberColor: session.color || '',
|
||||||
|
role: session.role
|
||||||
|
}
|
||||||
|
: null,
|
||||||
isParent,
|
isParent,
|
||||||
role: session?.role || 'child',
|
role,
|
||||||
famId,
|
famId,
|
||||||
chat,
|
chat,
|
||||||
deviceToken,
|
pbToken,
|
||||||
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
|
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
|
||||||
fam: famId
|
fam: famId
|
||||||
? await pbAdmin.getOne('fams', famId).catch(() => null)
|
? await pbAdmin.getOne('fams', famId).catch(() => null)
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { initPbFromCookie } from '$lib/pocketbase';
|
import { initRealtimePb } from '$lib/pocketbase';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initPbFromCookie();
|
initRealtimePb(page.data.pbToken || '');
|
||||||
if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
|
if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
|
||||||
const chat = page.data.chat;
|
const chat = page.data.chat;
|
||||||
if (chat?.famId && chat?.actor) {
|
if (chat?.famId && chat?.actor) {
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
actorType: chat.actor.type,
|
actorType: chat.actor.type,
|
||||||
actorName: chat.actor.name,
|
actorName: chat.actor.name,
|
||||||
actorColor: chat.actor.color || '#6366f1',
|
actorColor: chat.actor.color || '#6366f1',
|
||||||
deviceToken: page.data.deviceToken || ''
|
pbToken: page.data.pbToken || ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { hono } from '$lib/server/hono';
|
import { pbUser } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session;
|
const session = event.locals.user;
|
||||||
if (!session) return {};
|
if (!session) return {};
|
||||||
|
|
||||||
const famId = session.famId;
|
const famId = session.famId;
|
||||||
|
const s = createServices(pbUser(event), session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [members, templates, assigned, summary] = await Promise.all([
|
const [members, templates, assigned, summary] = await Promise.all([
|
||||||
hono.admin.list(event, 'members', famId),
|
s.crud.list('members', famId),
|
||||||
hono.admin.list(event, 'chore-templates', famId),
|
s.crud.list('chore-templates', famId),
|
||||||
hono.admin.list(event, 'assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
hono.admin.weeklySummary(event, famId),
|
s.fam.weeklySummary(famId),
|
||||||
]);
|
]);
|
||||||
return { members, templates, assigned, summary };
|
return { members, templates, assigned, summary };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import { formatDDMMYY } from '$lib/format';
|
import { formatDDMMYY } from '$lib/format';
|
||||||
|
import { handleOf } from '@shared/slugify';
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
<span class="dot" style="background:{s.memberColor}"></span>
|
<span class="dot" style="background:{s.memberColor}"></span>
|
||||||
<span class="member-name">{s.memberName}</span>
|
<span class="member-name">{s.memberName}</span>
|
||||||
{#if m}
|
{#if m}
|
||||||
<a href="/{famSlug}/{m.name}" class="kanban-link">Kanban →</a>
|
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="donut-wrap">
|
<div class="donut-wrap">
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { pbUser, createPbClient } from '$lib/server/pocketbase';
|
||||||
import { PROXY_URL } from '$app/env/public';
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
const HONO_URL = PROXY_URL;
|
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session;
|
const session = event.locals.user;
|
||||||
|
|
||||||
// Parent (session auth) → admin overview
|
// Parent (role=parent, session auth) → admin overview.
|
||||||
if (session) {
|
// Branch on role, NOT presence: a child who OTP-logged-in also has a
|
||||||
|
// `users` session (locals.user) and must not hit the parent path.
|
||||||
|
if (session && session.role === 'parent') {
|
||||||
const famId = session.famId;
|
const famId = session.famId;
|
||||||
const famSlug = event.params.fam;
|
const famSlug = event.params.fam;
|
||||||
const username = event.params.username;
|
const username = event.params.username;
|
||||||
if (session.memberName && session.memberName !== username) {
|
if (session.name && session.username && session.username !== username) {
|
||||||
throw redirect(303, `/${famSlug}/${session.memberName}`);
|
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||||
}
|
}
|
||||||
|
const s = createServices(pbUser(event), session);
|
||||||
const [
|
const [
|
||||||
members,
|
members,
|
||||||
templates,
|
templates,
|
||||||
@@ -26,15 +27,15 @@ export async function load(event) {
|
|||||||
completions,
|
completions,
|
||||||
settings
|
settings
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
hono.admin.list(event, 'members', famId),
|
s.crud.list('members', famId),
|
||||||
hono.admin.list(event, 'chore-templates', famId),
|
s.crud.list('chore-templates', famId),
|
||||||
hono.admin.list(event, 'assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
hono.admin.weeklySummary(event, famId),
|
s.fam.weeklySummary(famId),
|
||||||
hono.admin.fam(event, famId),
|
s.fam.get(famId),
|
||||||
hono.admin.rewards(event, famId),
|
s.crud.list('rewards', famId),
|
||||||
hono.admin.bonusConfigs(event, famId),
|
s.crud.list('bonus-configs', famId),
|
||||||
hono.admin.completions(event, famId),
|
s.crud.list('completions', famId),
|
||||||
hono.admin.settings(event, famId).catch(() => ({}))
|
s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' }))
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
role: 'parent',
|
role: 'parent',
|
||||||
@@ -51,109 +52,76 @@ export async function load(event) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Child (device token) → kanban
|
// Child (users auth, role=child) → kanban
|
||||||
const deviceToken =
|
|
||||||
event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
|
||||||
const famSlug = event.params.fam;
|
const famSlug = event.params.fam;
|
||||||
const username = event.params.username;
|
const username = event.params.username;
|
||||||
|
|
||||||
if (!deviceToken) {
|
// A child should always land on their own kanban route.
|
||||||
return {
|
if (session?.username && session.username !== username) {
|
||||||
role: 'child',
|
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||||
token: '',
|
|
||||||
memberId: '',
|
|
||||||
verified: false,
|
|
||||||
famId: '',
|
|
||||||
templates: [],
|
|
||||||
assigned: [],
|
|
||||||
completions: [],
|
|
||||||
rewards: [],
|
|
||||||
bonusConfigs: [],
|
|
||||||
tallies: {}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pbToken = event.cookies.get('pb_token') || '';
|
||||||
|
const famId = session?.famId || '';
|
||||||
|
const childId = session?.id || '';
|
||||||
|
|
||||||
|
const empty = {
|
||||||
|
role: 'child' as const,
|
||||||
|
token: pbToken,
|
||||||
|
memberId: childId,
|
||||||
|
famId,
|
||||||
|
verified: false,
|
||||||
|
memberName: session?.name || '',
|
||||||
|
memberColor: session?.color || '',
|
||||||
|
templates: [] as never[],
|
||||||
|
assigned: [] as never[],
|
||||||
|
completions: [] as never[],
|
||||||
|
rewards: [] as never[],
|
||||||
|
bonusConfigs: [] as never[],
|
||||||
|
tallies: {} as Record<string, unknown>,
|
||||||
|
payday: 1,
|
||||||
|
paydayTime: '18:00',
|
||||||
|
timezone: 'auto'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!pbToken || !famId) return empty;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
const s = createServices(createPbClient(pbToken), session ?? undefined);
|
||||||
method: 'POST',
|
const chores = await s.chores.myChores(famId);
|
||||||
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 {
|
|
||||||
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'x-device-token': deviceToken, 'x-device-famid': data.famId }
|
|
||||||
});
|
|
||||||
chores = await choresRes.json();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role: 'child',
|
role: 'child',
|
||||||
token: deviceToken,
|
token: pbToken,
|
||||||
memberId: data.memberId,
|
memberId: childId,
|
||||||
famId: data.famId,
|
famId,
|
||||||
verified: true,
|
verified: true,
|
||||||
memberName: data.name,
|
memberName: session?.name || '',
|
||||||
memberColor: data.color,
|
memberColor: session?.color || '',
|
||||||
templates: chores.templates || [],
|
templates: chores.templates || [],
|
||||||
assigned: chores.assigned || [],
|
assigned: chores.assigned || [],
|
||||||
completions: chores.completions || [],
|
completions: chores.completions || [],
|
||||||
rewards: chores.rewards || [],
|
rewards: chores.rewards || [],
|
||||||
bonusConfigs: chores.bonusConfigs || [],
|
bonusConfigs: chores.bonusConfigs || [],
|
||||||
tallies: chores.tallies || {},
|
tallies: {} as Record<string, unknown>,
|
||||||
payday: chores.payday,
|
payday: chores.payday,
|
||||||
paydayTime: chores.paydayTime || '18:00',
|
paydayTime: chores.paydayTime || '18:00',
|
||||||
timezone: chores.timezone || 'auto',
|
timezone: chores.timezone || 'auto',
|
||||||
simulateEow: !!chores.simulateEow
|
simulateEow: !!chores.simulateEow
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return empty;
|
||||||
role: 'child',
|
|
||||||
token: deviceToken,
|
|
||||||
memberId: '',
|
|
||||||
verified: false,
|
|
||||||
famId: '',
|
|
||||||
templates: [],
|
|
||||||
assigned: [],
|
|
||||||
completions: [],
|
|
||||||
rewards: [],
|
|
||||||
bonusConfigs: [],
|
|
||||||
tallies: {},
|
|
||||||
payday: 1,
|
|
||||||
paydayTime: '18:00',
|
|
||||||
timezone: 'auto'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
setEow: async (event) => {
|
setEow: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const on = fd.get('on') === 'true';
|
const on = fd.get('on') === 'true';
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.updateSettings(event, famId, { simulateEow: on });
|
const s = createServices(pbUser(event), event.locals.user);
|
||||||
|
const result = await s.settings.update(famId, { simulateEow: on });
|
||||||
return { simulateEow: result.simulateEow };
|
return { simulateEow: result.simulateEow };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
|
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
|
||||||
@@ -161,11 +129,12 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
previewEow: async (event) => {
|
previewEow: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
try {
|
try {
|
||||||
const preview = await hono.admin.eowPreview(event, famId);
|
const s = createServices(pbUser(event), event.locals.user);
|
||||||
await hono.admin.updateSettings(event, famId, { simulateEow: true });
|
const preview = await s.fam.eowPreview(famId);
|
||||||
|
await s.settings.update(famId, { simulateEow: true });
|
||||||
return { preview, simulateEow: true };
|
return { preview, simulateEow: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
||||||
@@ -173,12 +142,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
claim: async (event) => {
|
claim: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const rewardId = fd.get('id') as string;
|
const rewardId = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
const s = createServices(pbUser(event), event.locals.user);
|
||||||
|
const record = await s.rewards.approve(famId, rewardId);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||||
@@ -186,12 +156,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
issueAll: async (event) => {
|
issueAll: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.issueAllRewards(event, famId, memberId);
|
const s = createServices(pbUser(event), event.locals.user);
|
||||||
|
const result = await s.rewards.issueAll(famId, memberId);
|
||||||
return { count: result.count };
|
return { count: result.count };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
|
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
|
||||||
@@ -199,12 +170,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
revoke: async (event) => {
|
revoke: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const completionId = fd.get('id') as string;
|
const completionId = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
await hono.admin.revokeCompletion(event, famId, completionId);
|
const s = createServices(pbUser(event), event.locals.user);
|
||||||
|
await s.completions.revoke(famId, completionId);
|
||||||
return { revoked: true, id: completionId };
|
return { revoked: true, id: completionId };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
|
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
|
||||||
@@ -212,22 +184,18 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
trigger: async (event) => {
|
trigger: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const configId = fd.get('configId') as string;
|
const configId = fd.get('configId') as string;
|
||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
if (!configId) return { error: 'Config ID required' };
|
if (!configId) return { error: 'Config ID required' };
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.triggerBonusConfig(
|
const s = createServices(pbUser(event), event.locals.user);
|
||||||
event,
|
const result = await s.bonuses.trigger(famId, configId, memberId || undefined);
|
||||||
famId,
|
|
||||||
configId,
|
|
||||||
memberId || undefined
|
|
||||||
);
|
|
||||||
return { records: result.records || [] };
|
return { records: result.records || [] };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
|
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||||
|
import { handleOf } from '@shared/slugify';
|
||||||
import {
|
import {
|
||||||
weekStart as tzWeekStart,
|
weekStart as tzWeekStart,
|
||||||
addDaysStr,
|
addDaysStr,
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
|
|
||||||
// ─── Child View (kanban) ───
|
// ─── Child View (kanban) ───
|
||||||
let memberId = $state(data.memberId || '');
|
let memberId = $state(data.memberId || '');
|
||||||
let deviceToken = $state(data.token || '');
|
let pbToken = $state(data.token || '');
|
||||||
let famId = $state(data.famId || '');
|
let famId = $state(data.famId || '');
|
||||||
let memberName = $state(data.memberName || '');
|
let memberName = $state(data.memberName || '');
|
||||||
let memberColor = $state(data.memberColor || '#6366f1');
|
let memberColor = $state(data.memberColor || '#6366f1');
|
||||||
@@ -209,9 +210,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (role !== 'child' || !isPaydayToday) return;
|
if (role !== 'child' || !isPaydayToday) return;
|
||||||
if (secondsLeft > 0 || eowFired) return;
|
if (secondsLeft > 0 || eowFired) return;
|
||||||
if (!deviceToken || !famId) return;
|
if (!pbToken || !famId) return;
|
||||||
eowFired = true;
|
eowFired = true;
|
||||||
memberApi.payday(deviceToken, famId).catch(() => {});
|
memberApi.payday(famId).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
function paydayWeekStart(): string {
|
function paydayWeekStart(): string {
|
||||||
@@ -476,26 +477,13 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (role === 'parent') return;
|
if (role === 'parent') return;
|
||||||
|
|
||||||
if (!deviceToken) {
|
|
||||||
deviceToken = localStorage.getItem('deviceToken') || '';
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('deviceToken', deviceToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deviceToken) {
|
|
||||||
error = 'No device token found. Use the link from your invite.';
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.verified && data.famId && data.memberId) {
|
if (data.verified && data.famId && data.memberId) {
|
||||||
memberId = data.memberId;
|
memberId = data.memberId;
|
||||||
famId = data.famId;
|
famId = data.famId;
|
||||||
loading = false;
|
loading = false;
|
||||||
} else {
|
} else {
|
||||||
error = 'Invalid device token. Use the link from your invite.';
|
error = 'Please open your invite link to get access.';
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -558,7 +546,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild);
|
await memberApi.toggleCompletion(famId, chore.id, todayChild);
|
||||||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
famStore.applyRecord('completions', optimistic, 'delete');
|
famStore.applyRecord('completions', optimistic, 'delete');
|
||||||
@@ -644,7 +632,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="dot" style="background:{m.color}"></span>
|
<span class="dot" style="background:{m.color}"></span>
|
||||||
<span class="member-name">{m.name}</span>
|
<span class="member-name">{m.name}</span>
|
||||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
<a href="/{famSlug}/{handleOf(m.username)}" class="link">Kanban</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||||||
@@ -972,15 +960,11 @@
|
|||||||
const old = memberName;
|
const old = memberName;
|
||||||
memberName = nameInput;
|
memberName = nameInput;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/members/me', {
|
const res = await fetch('/api/members', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ name: nameInput })
|
||||||
'x-device-token': deviceToken,
|
});
|
||||||
'x-device-famid': famId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ name: nameInput })
|
|
||||||
});
|
|
||||||
if (!res.ok) memberName = old;
|
if (!res.ok) memberName = old;
|
||||||
} catch {
|
} catch {
|
||||||
memberName = old;
|
memberName = old;
|
||||||
@@ -1035,15 +1019,11 @@
|
|||||||
memberColor = color;
|
memberColor = color;
|
||||||
showColorPicker = false;
|
showColorPicker = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/members/me', {
|
const res = await fetch('/api/members', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ color })
|
||||||
'x-device-token': deviceToken,
|
});
|
||||||
'x-device-famid': famId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ color })
|
|
||||||
});
|
|
||||||
if (!res.ok) memberColor = old;
|
if (!res.ok) memberColor = old;
|
||||||
} catch {
|
} catch {
|
||||||
memberColor = old;
|
memberColor = old;
|
||||||
@@ -1267,7 +1247,7 @@
|
|||||||
class="wr-cta"
|
class="wr-cta"
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
try {
|
try {
|
||||||
await memberApi.claimReward(deviceToken, famId, r.id);
|
await memberApi.claimReward(famId, r.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
claimError = e instanceof Error ? e.message : 'Claim failed';
|
claimError = e instanceof Error ? e.message : 'Claim failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
|
const s = servicesFor(event);
|
||||||
const [configs, templates, members, progress, rewards] = await Promise.all([
|
const [configs, templates, members, progress, rewards] = await Promise.all([
|
||||||
hono.admin.bonusConfigs(event, famId),
|
s.crud.list('bonus-configs', famId),
|
||||||
hono.admin.list(event, 'bonus-templates', famId),
|
s.crud.list('bonus-templates', famId),
|
||||||
hono.admin.list(event, 'members', famId),
|
s.crud.list('members', famId),
|
||||||
hono.admin.bonusConfigProgress(event, famId),
|
s.bonuses.progress(famId),
|
||||||
hono.admin.rewards(event, famId)
|
s.crud.list('rewards', famId)
|
||||||
]);
|
]);
|
||||||
return { configs, templates, members, progress, rewards };
|
return { configs, templates, members, progress, rewards };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
createTemplate: async (event) => {
|
createTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
name: fd.get('name'),
|
name: fd.get('name'),
|
||||||
@@ -34,7 +35,8 @@ export const actions = {
|
|||||||
if (period !== null) data.period = period;
|
if (period !== null) data.period = period;
|
||||||
if (data.occurrence === 'once') data.period = '';
|
if (data.occurrence === 'once') data.period = '';
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.create(event, 'bonus-templates', famId, data);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.crud.create('bonus-templates', famId, data);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
||||||
@@ -42,8 +44,8 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateTemplate: async (event) => {
|
updateTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
@@ -67,7 +69,8 @@ export const actions = {
|
|||||||
const description = fd.get('description');
|
const description = fd.get('description');
|
||||||
if (description) data.description = description;
|
if (description) data.description = description;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.update(event, 'bonus-templates', famId, id, data);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.crud.update('bonus-templates', famId, id, data);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
|
||||||
@@ -75,12 +78,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteTemplate: async (event) => {
|
deleteTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
await hono.admin.remove(event, 'bonus-templates', famId, id);
|
const s = servicesFor(event);
|
||||||
|
await s.crud.remove('bonus-templates', famId, id);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
||||||
@@ -88,8 +92,8 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createConfig: async (event) => {
|
createConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const startMode = fd.get('startMode') as string;
|
const startMode = fd.get('startMode') as string;
|
||||||
const status = startMode === 'disabled' ? 'disabled' : 'active';
|
const status = startMode === 'disabled' ? 'disabled' : 'active';
|
||||||
@@ -112,7 +116,8 @@ export const actions = {
|
|||||||
const memberId = fd.get('memberId');
|
const memberId = fd.get('memberId');
|
||||||
if (memberId) data.memberId = memberId;
|
if (memberId) data.memberId = memberId;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.crud.create('bonus-configs', famId, data);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
|
||||||
@@ -120,8 +125,8 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateConfig: async (event) => {
|
updateConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
@@ -147,7 +152,8 @@ export const actions = {
|
|||||||
const memberId = fd.get('memberId');
|
const memberId = fd.get('memberId');
|
||||||
if (memberId !== null) data.memberId = memberId || null;
|
if (memberId !== null) data.memberId = memberId || null;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.update(event, 'bonus-configs', famId, id, data);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.crud.update('bonus-configs', famId, id, data);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
|
||||||
@@ -155,12 +161,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteConfig: async (event) => {
|
deleteConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
await hono.admin.remove(event, 'bonus-configs', famId, id);
|
const s = servicesFor(event);
|
||||||
|
await s.crud.remove('bonus-configs', famId, id);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' });
|
||||||
@@ -168,8 +175,8 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createFromTemplate: async (event) => {
|
createFromTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const startMode = fd.get('startMode') as string;
|
const startMode = fd.get('startMode') as string;
|
||||||
const status = startMode === 'disabled' ? 'disabled' : 'active';
|
const status = startMode === 'disabled' ? 'disabled' : 'active';
|
||||||
@@ -188,7 +195,8 @@ export const actions = {
|
|||||||
};
|
};
|
||||||
if (data.occurrence === 'once') data.period = '';
|
if (data.occurrence === 'once') data.period = '';
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.crud.create('bonus-configs', famId, data);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
@@ -198,14 +206,15 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
assignConfig: async (event) => {
|
assignConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
const target = fd.get('target') as string;
|
const target = fd.get('target') as string;
|
||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.assignBonusConfig(event, famId, id, {
|
const s = servicesFor(event);
|
||||||
|
const record = await s.bonuses.assign(famId, id, {
|
||||||
target,
|
target,
|
||||||
memberId: memberId || null
|
memberId: memberId || null
|
||||||
});
|
});
|
||||||
@@ -216,12 +225,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
completeConfig: async (event) => {
|
completeConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.completeBonusConfig(event, famId, id);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.bonuses.complete(famId, id);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' });
|
||||||
@@ -229,12 +239,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
destroyConfig: async (event) => {
|
destroyConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
await hono.admin.destroyBonusConfig(event, famId, id);
|
const s = servicesFor(event);
|
||||||
|
await s.bonuses.destroy(famId, id);
|
||||||
return { destroyed: true };
|
return { destroyed: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
|
||||||
@@ -242,14 +253,15 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleConfig: async (event) => {
|
toggleConfig: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
const currentStatus = fd.get('currentStatus') as string;
|
const currentStatus = fd.get('currentStatus') as string;
|
||||||
const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled';
|
const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled';
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.update(event, 'bonus-configs', famId, id, {
|
const s = servicesFor(event);
|
||||||
|
const record = await s.crud.update('bonus-configs', famId, id, {
|
||||||
status: newStatus
|
status: newStatus
|
||||||
});
|
});
|
||||||
return { record };
|
return { record };
|
||||||
@@ -259,12 +271,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
evaluate: async (event) => {
|
evaluate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
try {
|
try {
|
||||||
await hono.admin.evaluateBonusConfig(event, famId);
|
const s = servicesFor(event);
|
||||||
|
await s.bonuses.evaluate(famId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
|
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { hono } from '$lib/server/hono';
|
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
|
|
||||||
export async function GET(event) {
|
export async function GET(event) {
|
||||||
const famId = event.params.fam;
|
const famId = event.params.fam;
|
||||||
try {
|
try {
|
||||||
const progress = await hono.admin.bonusConfigProgress(event, famId);
|
const s = servicesFor(event);
|
||||||
|
const progress = await s.bonuses.progress(famId);
|
||||||
return json(progress);
|
return json(progress);
|
||||||
} catch {
|
} catch {
|
||||||
return json([]);
|
return json([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,45 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { pbUser } from '$lib/server/pocketbase';
|
||||||
|
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
|
const pb = pbUser(event);
|
||||||
const [templates, members, assigned, seasons, completions] = await Promise.all([
|
const [templates, members, assigned, seasons, completions] = await Promise.all([
|
||||||
hono.admin.list(event, 'chore-templates', famId),
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
hono.admin.list(event, 'members', famId),
|
pb.collection('users').getFullList({
|
||||||
hono.admin.list(event, 'assigned-chores', famId),
|
filter: `famId = '${famId}' && role = 'child'`
|
||||||
hono.admin.list(event, 'seasons', famId),
|
}),
|
||||||
hono.admin.completions(event, famId),
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||||||
]);
|
]);
|
||||||
return { templates, members, assigned, seasons, completions };
|
return {
|
||||||
|
templates: templates as unknown as ChoreTemplate[],
|
||||||
|
members: members as unknown as Member[],
|
||||||
|
assigned: assigned as unknown as AssignedChore[],
|
||||||
|
seasons: seasons as unknown as Season[],
|
||||||
|
completions: completions as unknown as Completion[]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
createTemplate: async (event) => {
|
createTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const data = {
|
const data = {
|
||||||
name: fd.get('name'),
|
name: fd.get('name'),
|
||||||
defaultFrequency: fd.get('defaultFrequency'),
|
defaultFrequency: fd.get('defaultFrequency'),
|
||||||
defaultType: fd.get('defaultType'),
|
defaultType: fd.get('defaultType'),
|
||||||
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0,
|
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0
|
||||||
};
|
};
|
||||||
if (!data.name || !data.defaultFrequency || !data.defaultType) {
|
if (!data.name || !data.defaultFrequency || !data.defaultType) {
|
||||||
return fail(400, { error: 'Name, frequency, and type are required' });
|
return fail(400, { error: 'Name, frequency, and type are required' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.create(event, 'chore-templates', famId, data);
|
const record = await pbUser(event).collection('chore_templates').create({ famId, ...data });
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
||||||
@@ -37,8 +47,8 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateTemplate: async (event) => {
|
updateTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
const defaultFrequency = fd.get('defaultFrequency') as string;
|
const defaultFrequency = fd.get('defaultFrequency') as string;
|
||||||
@@ -48,20 +58,25 @@ export const actions = {
|
|||||||
name: fd.get('name'),
|
name: fd.get('name'),
|
||||||
defaultFrequency,
|
defaultFrequency,
|
||||||
defaultType,
|
defaultType,
|
||||||
defaultValue,
|
defaultValue
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.update(event, 'chore-templates', famId, id, data);
|
const pb = pbUser(event);
|
||||||
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId);
|
const record = await pb.collection('chore_templates').update(id, data);
|
||||||
|
const allAssigned = await pb
|
||||||
|
.collection('assigned_chores')
|
||||||
|
.getFullList({ filter: `famId = '${famId}'` });
|
||||||
if (Array.isArray(allAssigned)) {
|
if (Array.isArray(allAssigned)) {
|
||||||
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
|
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
|
||||||
await Promise.all(toUpdate.map((a: any) =>
|
await Promise.all(
|
||||||
hono.admin.update(event, 'assigned-chores', famId, a.id, {
|
toUpdate.map((a: any) =>
|
||||||
frequency: defaultFrequency,
|
pb.collection('assigned_chores').update(a.id, {
|
||||||
type: defaultType,
|
frequency: defaultFrequency,
|
||||||
value: defaultValue,
|
type: defaultType,
|
||||||
})
|
value: defaultValue
|
||||||
));
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -70,21 +85,21 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteTemplate: async (event) => {
|
deleteTemplate: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.remove(event, 'chore-templates', famId, id);
|
await pbUser(event).collection('chore_templates').delete(id);
|
||||||
return { record };
|
return {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAssignedChore: async (event) => {
|
updateAssignedChore: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
const isTodo = fd.get('isTodo') === '1';
|
const isTodo = fd.get('isTodo') === '1';
|
||||||
@@ -116,7 +131,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
|
const record = await pbUser(event).collection('assigned_chores').update(id, data);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
|
||||||
@@ -124,8 +139,8 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createTodo: async (event) => {
|
createTodo: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
@@ -167,15 +182,14 @@ export const actions = {
|
|||||||
customName: name,
|
customName: name,
|
||||||
isTodo: true,
|
isTodo: true,
|
||||||
startDate,
|
startDate,
|
||||||
completeBy,
|
completeBy
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
|
const record = await pbUser(event).collection('assigned_chores').create({ famId, ...data });
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
};
|
||||||
};
|
|
||||||
@@ -1,30 +1,32 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
|
const s = servicesFor(event);
|
||||||
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||||
hono.admin.rewards(event, famId),
|
s.crud.list('rewards', famId),
|
||||||
hono.admin.list(event, 'members', famId),
|
s.crud.list('members', famId),
|
||||||
hono.admin.list(event, 'assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
hono.admin.list(event, 'chore-templates', famId),
|
s.crud.list('chore-templates', famId),
|
||||||
hono.admin.completions(event, famId)
|
s.crud.list('completions', famId)
|
||||||
]);
|
]);
|
||||||
return { rewards, members, assigned, templates, completions };
|
return { rewards, members, assigned, templates, completions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
claim: async (event) => {
|
claim: async (event) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.user.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const rewardId = fd.get('id') as string;
|
const rewardId = fd.get('id') as string;
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
const s = servicesFor(event);
|
||||||
|
const record = await s.rewards.approve(famId, rewardId);
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
import { PROXY_URL } from '$app/env/public';
|
|
||||||
|
|
||||||
const HONO_URL = PROXY_URL;
|
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session;
|
const session = event.locals.user;
|
||||||
const famSlug = event.params.fam;
|
const famSlug = event.params.fam;
|
||||||
const username = event.params.username;
|
const username = event.params.username;
|
||||||
|
|
||||||
// Parent (session auth) — profile lives in fam_admins
|
if (!session) {
|
||||||
if (session) {
|
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||||
const famId = session.famId;
|
}
|
||||||
if (session.memberName && session.memberName !== username) {
|
|
||||||
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`);
|
const famId = session.famId;
|
||||||
}
|
if (session.username && session.username !== username) {
|
||||||
|
throw redirect(303, `/${famSlug}/${session.username}/preferences`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = servicesFor(event);
|
||||||
|
|
||||||
|
if (session.role === 'parent') {
|
||||||
try {
|
try {
|
||||||
const me = await hono.admin.getProfile(event, famId);
|
const me = await s.fam.getProfile(famId);
|
||||||
return {
|
return {
|
||||||
verified: true, token: '', memberId: me.id, famId,
|
verified: true, token: '', memberId: me.id, famId,
|
||||||
memberName: me.name, memberColor: me.color, email: me.email || '',
|
memberName: me.name, memberColor: me.color, email: me.email || '',
|
||||||
@@ -27,73 +30,49 @@ export async function load(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Child (device token) — profile lives in members
|
// Child — profile lives on the users record, read from the session.
|
||||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
return {
|
||||||
if (!deviceToken) {
|
verified: true, token: event.cookies.get('pb_token') || '', memberId: session.id, famId,
|
||||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
memberName: session.name || '', memberColor: session.color || '', email: '',
|
||||||
}
|
session: true,
|
||||||
|
};
|
||||||
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 = {
|
export const actions = {
|
||||||
update: async (event) => {
|
update: async (event) => {
|
||||||
const session = event.locals.session;
|
const session = event.locals.user;
|
||||||
|
if (!session) return { error: 'Not authenticated' };
|
||||||
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
const color = fd.get('color') as string;
|
const color = fd.get('color') as string;
|
||||||
const email = fd.get('email') as string;
|
const email = fd.get('email') as string;
|
||||||
|
|
||||||
if (session) {
|
const s = servicesFor(event);
|
||||||
const famId = session.famId;
|
const famId = session.famId;
|
||||||
|
|
||||||
|
if (session.role === 'parent') {
|
||||||
try {
|
try {
|
||||||
const data: Record<string, string> = {};
|
const data: Record<string, string> = {};
|
||||||
if (name) data.name = name;
|
if (name) data.name = name;
|
||||||
if (color) data.color = color;
|
if (color) data.color = color;
|
||||||
data.email = email || '';
|
data.email = email || '';
|
||||||
const me = await hono.admin.updateProfile(event, famId, data);
|
const me = await s.fam.updateProfile(famId, data);
|
||||||
return { success: true, name: me.name, color: me.color, email: me.email };
|
return { success: true, name: me.name, color: me.color, email: me.email };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
// Child — update own users record via their PB token.
|
||||||
const famSlug = event.params.fam;
|
|
||||||
if (!deviceToken) return { error: 'Not authenticated' };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
const data: Record<string, string> = {};
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
if (name) data.name = name;
|
||||||
body: JSON.stringify({ deviceToken, famSlug }),
|
if (color) data.color = color;
|
||||||
});
|
const me = await s.fam.updateProfile(famId, data);
|
||||||
const verify = await verifyRes.json();
|
return { success: true, name: me.name, color: me.color, email: '' };
|
||||||
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) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,55 +1,81 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { pbUser } from '$lib/server/pocketbase';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
|
import { issueAccess, createChild } from '$lib/server/member-otp';
|
||||||
|
import { slugify } from '@shared/slugify';
|
||||||
|
|
||||||
|
function famIdOf(event: RequestEvent): string {
|
||||||
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
|
return event.locals.user.famId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function load(event: RequestEvent) {
|
export async function load(event: RequestEvent) {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
const pb = pbUser(event);
|
||||||
const [members, fam, seasons] = await Promise.all([
|
const [members, fam, seasons] = await Promise.all([
|
||||||
hono.admin.list(event, 'members', famId),
|
pb.collection('users').getFullList({
|
||||||
hono.admin.fam(event, famId),
|
filter: `famId = '${famId}' && role = 'child'`
|
||||||
hono.admin.list(event, 'seasons', famId),
|
}),
|
||||||
|
pbAdmin.getOne('fams', famId),
|
||||||
|
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
|
||||||
]);
|
]);
|
||||||
return { members, fam, seasons };
|
return { members, fam, seasons };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
regenInvite: async (event: RequestEvent) => {
|
addMember: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
const famSlug: string = event.params.fam as string;
|
||||||
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode };
|
const fd = await event.request.formData();
|
||||||
|
const name = (fd.get('name') as string) || '';
|
||||||
|
const colour = (fd.get('colour') as string) || '#6366f1';
|
||||||
|
if (!name) return { error: 'Name required' };
|
||||||
|
try {
|
||||||
|
await createChild({ famId, famSlug, name, colour });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e.message : 'Failed to add member' };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addMember: async (event: RequestEvent) => {
|
issueAccess: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
const famSlug: string = event.params.fam as string;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const name = fd.get('name') as string;
|
const name = (fd.get('name') || '').toString().trim();
|
||||||
if (!name) return { error: 'Name required' };
|
if (!name) return { error: 'Select a child to invite' };
|
||||||
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' });
|
try {
|
||||||
|
const result = await issueAccess({ famId, famSlug, name });
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e.message : 'Failed to issue access' };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
renameFam: async (event: RequestEvent) => {
|
renameFam: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
if (!name) return { error: 'Name required' };
|
if (!name) return { error: 'Name required' };
|
||||||
return await hono.admin.renameFam(event, famId, name);
|
const record = await pbUser(event)
|
||||||
|
.collection('fams')
|
||||||
|
.update(famId, { name, slug: slugify(name) });
|
||||||
|
return { name: record.name, slug: record.slug };
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMember: async (event: RequestEvent) => {
|
deleteMember: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
if (!id) return { error: 'Member ID required' };
|
if (!id) return { error: 'Member ID required' };
|
||||||
await hono.admin.remove(event, 'members', famId, id);
|
await pbUser(event).collection('users').delete(id);
|
||||||
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePayday: async (event: RequestEvent) => {
|
updatePayday: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const payday = parseInt(fd.get('payday') as string, 10);
|
const payday = parseInt(fd.get('payday') as string, 10);
|
||||||
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
||||||
@@ -59,45 +85,51 @@ export const actions = {
|
|||||||
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
|
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
|
||||||
return { error: 'Timezone must be an IANA name or auto' };
|
return { error: 'Timezone must be an IANA name or auto' };
|
||||||
}
|
}
|
||||||
return await hono.admin.updatePayday(event, famId, payday, paydayTime, timezone || undefined);
|
const patch: Record<string, unknown> = { payday };
|
||||||
|
if (paydayTime) patch.paydayTime = paydayTime;
|
||||||
|
if (timezone) patch.timezone = timezone;
|
||||||
|
const record = await pbUser(event).collection('fams').update(famId, patch);
|
||||||
|
return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone };
|
||||||
},
|
},
|
||||||
|
|
||||||
createSeason: async (event: RequestEvent) => {
|
createSeason: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
const color = fd.get('color') as string;
|
const color = fd.get('color') as string;
|
||||||
if (!name) return { error: 'Name required' };
|
if (!name) return { error: 'Name required' };
|
||||||
return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true });
|
return await pbUser(event)
|
||||||
|
.collection('seasons')
|
||||||
|
.create({ famId, name, color: color || '#6366f1', active: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSeason: async (event: RequestEvent) => {
|
deleteSeason: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const id = fd.get('id') as string;
|
const id = fd.get('id') as string;
|
||||||
if (!id) return { error: 'Season ID required' };
|
if (!id) return { error: 'Season ID required' };
|
||||||
|
|
||||||
const assigned = await hono.admin.list(event, 'assigned-chores', famId);
|
const pb = pbUser(event);
|
||||||
const toDelete = (Array.isArray(assigned) ? assigned : [])
|
const assigned = await pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` });
|
||||||
.filter((a: any) => a.seasonIds?.includes(id));
|
const toDelete = (Array.isArray(assigned) ? assigned : []).filter(
|
||||||
|
(a: any) => a.seasonIds?.includes(id)
|
||||||
|
);
|
||||||
const deletedIds = toDelete.map((a: any) => a.id);
|
const deletedIds = toDelete.map((a: any) => a.id);
|
||||||
|
|
||||||
await Promise.all(toDelete.map((a: any) =>
|
await Promise.all(
|
||||||
hono.admin.remove(event, 'assigned-chores', famId, a.id)
|
toDelete.map((a: any) => pb.collection('assigned_chores').delete(a.id))
|
||||||
));
|
);
|
||||||
|
await pb.collection('seasons').delete(id);
|
||||||
await hono.admin.remove(event, 'seasons', famId, id);
|
|
||||||
|
|
||||||
return { deletedChoreIds: deletedIds };
|
return { deletedChoreIds: deletedIds };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Compute endpoints — in-process.
|
||||||
completeWeek: async (event: RequestEvent) => {
|
completeWeek: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
|
const s = servicesFor(event);
|
||||||
|
const result = await s.fam.completeWeek(famId);
|
||||||
return { success: true, result };
|
return { success: true, result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to complete week' };
|
return { error: e instanceof Error ? e.message : 'Failed to complete week' };
|
||||||
@@ -105,15 +137,15 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
generateData: async (event: RequestEvent) => {
|
generateData: async (event: RequestEvent) => {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
const famId = famIdOf(event);
|
||||||
const famId = event.locals.session.famId;
|
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const days = parseInt(fd.get('days') as string || '7', 10);
|
const days = parseInt((fd.get('days') as string) || '7', 10);
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
|
const s = servicesFor(event);
|
||||||
|
const result = await s.debug.generateData(famId, days);
|
||||||
return { success: true, result };
|
return { success: true, result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -4,20 +4,20 @@
|
|||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import { COMMON_TIMEZONES } from '@shared/timezone';
|
import { COMMON_TIMEZONES } from '@shared/timezone';
|
||||||
|
import { handleOf } from '@shared/slugify';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the
|
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
|
||||||
// public famStore stream. It is superadmin-only, fetched server-side by the
|
// public famStore stream. It is superadmin-only, fetched server-side by the
|
||||||
// layout load. Writes go through form actions; no live fam subscription.
|
// layout load. Writes go through form actions; no live fam subscription.
|
||||||
let fam = $state(data.fam);
|
let fam = $state(data.fam);
|
||||||
let famSlug = $state(page.params.fam);
|
let famSlug = $state(page.params.fam);
|
||||||
|
|
||||||
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`);
|
|
||||||
let addName = $state('');
|
let addName = $state('');
|
||||||
let rename = $state('');
|
let rename = $state('');
|
||||||
let selectedMember = $state('');
|
let inviteChild = $state('');
|
||||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||||||
let paydayTime = $state(fam?.paydayTime || '18:00');
|
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||||||
let paydayTimes = $state([
|
let paydayTimes = $state([
|
||||||
@@ -56,12 +56,16 @@
|
|||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let parentInviteEmail = $state('');
|
let parentInviteEmail = $state('');
|
||||||
|
|
||||||
let selectedInviteUrl = $derived(
|
|
||||||
selectedMember ? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}` : childInviteUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
let members = $state(famStore.initialized ? famStore.members : data.members || []);
|
let members = $state(famStore.initialized ? famStore.members : data.members || []);
|
||||||
let deletingSeason = $state<any>(null);
|
let deletingSeason = $state<any>(null);
|
||||||
|
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
let invitePath = $derived(
|
||||||
|
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
|
||||||
|
);
|
||||||
|
let inviteUrl = $derived(
|
||||||
|
issued ? `${page.url.origin}${invitePath}` : ''
|
||||||
|
);
|
||||||
|
|
||||||
function copy(url: string) {
|
function copy(url: string) {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
@@ -73,6 +77,15 @@
|
|||||||
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
|
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleQR() {
|
||||||
|
showQR = !showQR;
|
||||||
|
if (!showQR) {
|
||||||
|
qrDataUrl = '';
|
||||||
|
} else {
|
||||||
|
generateQR(inviteUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleParentInvite() {
|
function handleParentInvite() {
|
||||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||||||
}
|
}
|
||||||
@@ -82,37 +95,60 @@
|
|||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card title="Family Name">
|
<Card title="Family Name">
|
||||||
|
<p class="hint">
|
||||||
|
This is the name shown to your family. The address stays at
|
||||||
|
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||||||
|
working.
|
||||||
|
</p>
|
||||||
<form method="POST" action="?/renameFam" use:enhance>
|
<form method="POST" action="?/renameFam" use:enhance>
|
||||||
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
<label class="field-label" for="fam-name">Display name</label>
|
||||||
|
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
||||||
<Button type="submit" size="sm">Rename</Button>
|
<Button type="submit" size="sm">Rename</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{#if fam?.slug}
|
||||||
|
<p class="hint slug-line">
|
||||||
|
Family page: <code class="slug-inline">/{fam.slug}</code>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Members ({members.length})" cols={2}>
|
<Card title="Members ({members.length})" cols={2}>
|
||||||
<form method="POST" action="?/addMember" use:enhance>
|
<div class="members-grid">
|
||||||
<input name="name" bind:value={addName} placeholder="Member name" required />
|
<div class="members-add">
|
||||||
<Button type="submit" size="sm">Add</Button>
|
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||||
</form>
|
<form method="POST" action="?/addMember" use:enhance>
|
||||||
|
<label class="field-label" for="new-child">New child</label>
|
||||||
|
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
||||||
|
<Button type="submit" size="sm">Add child</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul>
|
<ul class="members-list">
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
<li>
|
<li>
|
||||||
<span class="dot" style="background:{m.color}"></span>
|
<span class="member-left">
|
||||||
{m.name}
|
<span class="member-color" style="background:{m.color}"></span>
|
||||||
{m.deviceToken ? '' : ' (pending join)'}
|
<span class="member-info">
|
||||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
<span class="member-name">{m.name}</span>
|
||||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||||||
<input type="hidden" name="id" value={m.id} />
|
</span>
|
||||||
<Button
|
</span>
|
||||||
type="submit"
|
<span class="member-actions">
|
||||||
variant="danger"
|
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
||||||
size="sm"
|
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||||
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
<input type="hidden" name="id" value={m.id} />
|
||||||
>
|
<Button
|
||||||
</form>
|
type="submit"
|
||||||
</li>
|
variant="danger"
|
||||||
{/each}
|
size="sm"
|
||||||
</ul>
|
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Payday" cols={1}>
|
<Card title="Payday" cols={1}>
|
||||||
@@ -166,54 +202,70 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Invite Children" cols={1}>
|
<Card title="Invite Children" cols={1}>
|
||||||
<p class="code">{fam?.inviteCode || '...'}</p>
|
<form
|
||||||
|
method="POST"
|
||||||
<div class="invite-row">
|
action="?/issueAccess"
|
||||||
<label>Member:</label>
|
use:enhance={() => {
|
||||||
<select bind:value={selectedMember}>
|
return async ({ formData, result }) => {
|
||||||
<option value="">— General link —</option>
|
if (result.type === 'success' && result.data?.ok) {
|
||||||
|
showQR = false;
|
||||||
|
qrDataUrl = '';
|
||||||
|
issued = {
|
||||||
|
otp: result.data.otp,
|
||||||
|
joinUrl: result.data.joinUrl,
|
||||||
|
name: String(formData.get('name') || '')
|
||||||
|
};
|
||||||
|
} else if (result.type === 'success' && result.data?.error) {
|
||||||
|
alert(result.data.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="invite-form"
|
||||||
|
>
|
||||||
|
<label class="field-label" for="invite-child">Child</label>
|
||||||
|
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
||||||
|
<option value="">— Select a child —</option>
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
<option value={m.name}>{m.name}</option>
|
<option value={m.name}>{m.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
||||||
<p class="hint">Re-joining a member will disconnect their old device.</p>
|
</form>
|
||||||
|
<p class="hint">
|
||||||
|
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||||||
|
</p>
|
||||||
|
|
||||||
<p class="invite-url">{selectedInviteUrl}</p>
|
{#if issued?.otp}
|
||||||
|
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||||
<div class="actions">
|
<p class="text-xs text-slate-500">
|
||||||
<Button onclick={() => copy(selectedInviteUrl)} size="sm">
|
Code for {issued.name} (valid 20 min):
|
||||||
{copied ? 'Copied!' : 'Copy link'}
|
</p>
|
||||||
</Button>
|
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
||||||
|
{issued.otp}
|
||||||
<Button
|
</p>
|
||||||
onclick={async () => {
|
<p class="invite-url">{invitePath}</p>
|
||||||
showQR = !showQR;
|
<div class="actions justify-center">
|
||||||
if (!showQR) {
|
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
|
||||||
qrDataUrl = '';
|
{copied ? 'Copied!' : 'Copy URL'}
|
||||||
} else {
|
</Button>
|
||||||
await generateQR(selectedInviteUrl);
|
<Button variant="secondary" size="sm" onclick={toggleQR}>
|
||||||
}
|
{showQR ? 'Hide QR' : 'Show QR'}
|
||||||
}}
|
</Button>
|
||||||
size="sm"
|
</div>
|
||||||
>
|
{#if showQR && qrDataUrl}
|
||||||
{showQR ? 'Hide QR' : 'Show QR'}
|
<div class="qr-wrap">
|
||||||
</Button>
|
<img src={qrDataUrl} alt="QR Code" class="qr" />
|
||||||
|
</div>
|
||||||
<form method="POST" action="?/regenInvite" use:enhance class="inline">
|
{/if}
|
||||||
<Button type="submit" size="sm">Renew code</Button>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showQR && qrDataUrl}
|
|
||||||
<img src={qrDataUrl} alt="QR Code" class="qr" />
|
|
||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Invite Parent" cols={1}>
|
<Card title="Invite Parent" cols={1}>
|
||||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||||
<div class="invite-row">
|
<div class="invite-form">
|
||||||
<input type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
<label class="field-label" for="parent-email">Parent email</label>
|
||||||
|
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
||||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">They will set up their own password on first login.</p>
|
<p class="hint">They will set up their own password on first login.</p>
|
||||||
@@ -222,9 +274,13 @@
|
|||||||
<Card title="Seasons" cols={1}>
|
<Card title="Seasons" cols={1}>
|
||||||
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
||||||
|
|
||||||
<form method="POST" action="?/createSeason" use:enhance class="inline">
|
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
||||||
<input name="name" placeholder="Season name" required />
|
<label class="field-label" for="season-name">New season</label>
|
||||||
<input name="color" type="color" value="#6366f1" class="color-input" />
|
<input id="season-name" name="name" placeholder="Season name" required />
|
||||||
|
<div class="color-row">
|
||||||
|
<label for="season-color">Colour</label>
|
||||||
|
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
||||||
|
</div>
|
||||||
<Button type="submit" size="sm">Add</Button>
|
<Button type="submit" size="sm">Add</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -310,54 +366,74 @@
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.section {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.code {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.invite-url {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
word-break: break-all;
|
|
||||||
background: #f9fafb;
|
|
||||||
padding: 0.4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.invite-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.invite-row select {
|
|
||||||
padding: 0.4rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.invite-row input {
|
|
||||||
padding: 0.4rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.payday-form {
|
.invite-url {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Forms: full-width, balanced fields ── */
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.payday-form button[type='submit'] {
|
form.inline {
|
||||||
align-self: flex-start;
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: 2px solid #6366f1;
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
/* Buttons are rendered by <Button> (scoped in its own component) — reach
|
||||||
|
them with :global so primary CTAs stretch to full width. */
|
||||||
|
form :global(.btn) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
form.inline :global(.btn) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.invite-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.invite-form :global(.btn) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Payday ── */
|
||||||
.payday-row {
|
.payday-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -373,31 +449,51 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.payday-row + .payday-row {
|
.payday-row + .payday-row {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
.actions {
|
|
||||||
|
/* ── Colour rows (seasons) ── */
|
||||||
|
.color-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
margin-top: 0.5rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.qr {
|
.color-row label {
|
||||||
margin-top: 0.5rem;
|
font-size: 0.8rem;
|
||||||
border: 1px solid #e5e7eb;
|
font-weight: 500;
|
||||||
border-radius: 4px;
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.color-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 160px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lists ── */
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
li {
|
li {
|
||||||
padding: 0.3rem 0;
|
padding: 0.45rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -410,18 +506,121 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
form {
|
.slug-inline {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.slug-line {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Members: two-column (add | list) ── */
|
||||||
|
.members-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.members-add form {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.members-list {
|
||||||
|
border-left: 1px solid #f3f4f6;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
.member-left,
|
||||||
|
.member-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.member-color {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.member-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.member-handle {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.member-actions :global(.btn) {
|
||||||
|
flex: none;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
@container (max-width: 380px) {
|
||||||
|
.members-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.members-list {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action rows ── */
|
||||||
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-top: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
input,
|
.actions.justify-center {
|
||||||
select,
|
justify-content: center;
|
||||||
button {
|
|
||||||
padding: 0.4rem 0.7rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
.actions :global(.btn) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.qr-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.qr {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Narrow cards: stack rows / actions full width (container query) ── */
|
||||||
|
@container (max-width: 380px) {
|
||||||
|
.payday-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.payday-row select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.color-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.actions :global(.btn) {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlay / modal (plain buttons live in this template) ── */
|
||||||
button {
|
button {
|
||||||
background: #6366f1;
|
background: #6366f1;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -437,18 +636,6 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
}
|
}
|
||||||
.inline {
|
|
||||||
display: inline;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.color-input {
|
|
||||||
width: 40px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -462,8 +649,9 @@
|
|||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
min-width: 320px;
|
width: 100%;
|
||||||
max-width: 440px;
|
max-width: 440px;
|
||||||
|
margin: 0 1rem;
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
.modal h3 {
|
.modal h3 {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pbAdmin } from '$lib/server/pb-admin';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
import { redirect, fail } from '@sveltejs/kit';
|
import { redirect, fail } from '@sveltejs/kit';
|
||||||
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -12,17 +12,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
try {
|
try {
|
||||||
const fams = await pbAdmin.getList('fams');
|
const fams = await pbAdmin.getList('fams');
|
||||||
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
||||||
const [members, rewards, famAdmins] = await Promise.all([
|
const [members, rewards, parents] = await Promise.all([
|
||||||
pbAdmin.getList('members', `famId = '${fam.id}'`),
|
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
|
||||||
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
||||||
pbAdmin.getList('fam_admins', `famId = '${fam.id}'`),
|
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
|
id: fam.id, name: fam.name, slug: fam.slug,
|
||||||
memberCount: members.length,
|
memberCount: members.length,
|
||||||
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
|
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
|
||||||
totalRewards: rewards.length,
|
totalRewards: rewards.length,
|
||||||
parentEmail: (famAdmins as any[])?.[0]?.email || '',
|
parentEmail: (parents as any[])?.[0]?.email || '',
|
||||||
featureFlags: fam.featureFlags || {},
|
featureFlags: fam.featureFlags || {},
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
if (u.famId !== event.params.famId) {
|
||||||
|
return json({ error: 'famId mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
const record = await s.crud.create('assigned-chores', u.famId, body);
|
||||||
|
return json(record);
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'create failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
|
export async function DELETE(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
if (u.famId !== event.params.famId) {
|
||||||
|
return json({ error: 'famId mismatch' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const id = event.params.id!;
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
await s.crud.remove('assigned-chores', u.famId, id);
|
||||||
|
return json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'delete failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices, type ChatActor } from '$lib/server/services';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
action: 'send' | 'typing';
|
||||||
|
famId?: string;
|
||||||
|
content?: string;
|
||||||
|
clientId?: string;
|
||||||
|
typing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const body = (await event.request.json().catch(() => null)) as Body | null;
|
||||||
|
if (!body || !body.action) return json({ error: 'missing action' }, { status: 400 });
|
||||||
|
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const actor: ChatActor = {
|
||||||
|
id: u.id,
|
||||||
|
type: u.role === 'parent' ? 'admin' : 'member',
|
||||||
|
name: u.name,
|
||||||
|
color: u.color || '#6366f1'
|
||||||
|
};
|
||||||
|
const famId = body.famId || u.famId || '';
|
||||||
|
if (!famId) return json({ error: 'famId required' }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
const data =
|
||||||
|
body.action === 'typing'
|
||||||
|
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
|
||||||
|
: await s.chat.send(famId, actor, {
|
||||||
|
content: body.content || '',
|
||||||
|
clientId: body.clientId || ''
|
||||||
|
});
|
||||||
|
return json(data);
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'chat request failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
return json(await s.completions.toggle(u.famId, body));
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
return json(await s.fam.payday(u.famId));
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'payday failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
|
export async function PATCH(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
return json(await s.fam.updateProfile(u.famId, body));
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'update failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { createServices } from '$lib/server/services';
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const id = event.params.id!;
|
||||||
|
try {
|
||||||
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
|
return json(await s.rewards.claim(u.famId, id));
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import { PROXY_URL } from '$app/env/public';
|
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
const HONO_URL = PROXY_URL;
|
|
||||||
|
|
||||||
type Body = {
|
|
||||||
action: 'send' | 'typing';
|
|
||||||
famId?: string;
|
|
||||||
content?: string;
|
|
||||||
clientId?: string;
|
|
||||||
typing?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(event: RequestEvent) {
|
|
||||||
const body = (await event.request.json().catch(() => null)) as Body | null;
|
|
||||||
if (!body || !body.action) return json({ error: 'missing action' }, 400);
|
|
||||||
|
|
||||||
const session = event.locals.session;
|
|
||||||
const deviceToken = event.cookies.get('device_token') || '';
|
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
let famId = body.famId || '';
|
|
||||||
|
|
||||||
if (session?.famId && session?.userId) {
|
|
||||||
// Admin (parent) — trust the verified session server-side.
|
|
||||||
headers['x-session-famid'] = session.famId;
|
|
||||||
headers['x-session-userid'] = session.userId;
|
|
||||||
famId = session.famId;
|
|
||||||
} else if (deviceToken) {
|
|
||||||
// Member (child) — forward the device token; the proxy re-validates.
|
|
||||||
headers['x-device-token'] = deviceToken;
|
|
||||||
headers['x-device-famid'] = famId;
|
|
||||||
} else {
|
|
||||||
return json({ error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!famId) return json({ error: 'famId required' }, 400);
|
|
||||||
|
|
||||||
const path = body.action === 'typing' ? `/api/chat/${famId}/typing` : `/api/chat/${famId}/messages`;
|
|
||||||
const payload =
|
|
||||||
body.action === 'typing'
|
|
||||||
? { typing: Boolean(body.typing) }
|
|
||||||
: { content: body.content || '', clientId: body.clientId || '' };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${HONO_URL}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) return json({ error: data.error || 'chat request failed' }, res.status);
|
|
||||||
return json(data);
|
|
||||||
} catch {
|
|
||||||
return json({ error: 'chat request failed' }, 502);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
|
||||||
import { joinMember, setDeviceTokenCookie } from '$lib/server/auth';
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
default: async (event) => {
|
|
||||||
const code = event.params.code;
|
|
||||||
const fd = await event.request.formData();
|
|
||||||
const name = fd.get('name') as string;
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return fail(400, { error: 'Name is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceToken = crypto.randomUUID();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await joinMember(code, name, deviceToken);
|
|
||||||
setDeviceTokenCookie(event, deviceToken);
|
|
||||||
throw redirect(303, `/${result.famSlug}/${result.name}`);
|
|
||||||
} catch (e) {
|
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import AuthShell from '$lib/components/AuthShell.svelte';
|
|
||||||
|
|
||||||
let { form } = $props();
|
|
||||||
let name = $state('');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AuthShell title="Join your family" subtitle="Enter your name to join. It must match a member slot created by your admin.">
|
|
||||||
{#if form?.error}
|
|
||||||
<p class="form-error">{form.error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form method="POST">
|
|
||||||
<label>
|
|
||||||
Your name
|
|
||||||
<input name="name" bind:value={name} placeholder="Your exact name" required />
|
|
||||||
</label>
|
|
||||||
<button type="submit">Join</button>
|
|
||||||
</form>
|
|
||||||
</AuthShell>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
form {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.9rem;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4338ca;
|
|
||||||
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
background: #4338ca;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { background: #3730a3; }
|
|
||||||
.form-error {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #b91c1c;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
|
||||||
import { setDeviceTokenCookie } from '$lib/server/auth';
|
|
||||||
import { PROXY_URL } from '$app/env/public';
|
|
||||||
|
|
||||||
const HONO_URL = PROXY_URL;
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
default: async (event) => {
|
|
||||||
const code = event.params.code;
|
|
||||||
const name = event.params.member;
|
|
||||||
|
|
||||||
const res = await fetch(`${HONO_URL}/api/members/direct-join`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ inviteCode: code, name }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return fail(400, { error: data.error || 'Join failed' });
|
|
||||||
|
|
||||||
setDeviceTokenCookie(event, data.deviceToken);
|
|
||||||
|
|
||||||
throw redirect(303, `/${data.famSlug}/${data.name}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import AuthShell from '$lib/components/AuthShell.svelte';
|
|
||||||
|
|
||||||
let { form } = $props();
|
|
||||||
let memberName = $derived(page.params.member);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AuthShell
|
|
||||||
title="Join as {memberName}"
|
|
||||||
subtitle="You've been invited to your family's chore board. One tap and you're in."
|
|
||||||
>
|
|
||||||
{#if form?.error}
|
|
||||||
<p class="form-error">{form.error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form method="POST" use:enhance>
|
|
||||||
<button type="submit">Join as {memberName}</button>
|
|
||||||
</form>
|
|
||||||
</AuthShell>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
background: #4338ca;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { background: #3730a3; }
|
|
||||||
.form-error {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #b91c1c;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { setSessionCookie } from '$lib/server/session';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
import { handleOf } from '@shared/slugify';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
|
const fd = await event.request.formData();
|
||||||
const fd = await event.request.formData();
|
|
||||||
const email = fd.get('email') as string;
|
const email = fd.get('email') as string;
|
||||||
const password = fd.get('password') as string;
|
const password = fd.get('password') as string;
|
||||||
|
|
||||||
@@ -12,22 +14,32 @@ export const actions = {
|
|||||||
return fail(400, { error: 'Email and password required', email });
|
return fail(400, { error: 'Email and password required', email });
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: any;
|
let authResult: { token: string; record: any };
|
||||||
try {
|
try {
|
||||||
result = await login(email, password);
|
authResult = await createPbClient()
|
||||||
} catch (e) {
|
.collection('users')
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email });
|
.authWithPassword(email, password);
|
||||||
|
} catch {
|
||||||
|
return fail(400, { error: 'Invalid email or password', email });
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionCookie(event, {
|
const user = authResult.record;
|
||||||
famId: result.famId,
|
if (!user.famId) {
|
||||||
userId: result.userId,
|
return fail(400, { error: 'No family linked to this account', email });
|
||||||
famSlug: result.famSlug,
|
}
|
||||||
memberName: result.memberName,
|
|
||||||
role: result.role,
|
|
||||||
});
|
|
||||||
setPbTokenCookie(event, result.token);
|
|
||||||
|
|
||||||
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
|
setSessionCookie(event.cookies, authResult.token);
|
||||||
},
|
|
||||||
};
|
const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
|
||||||
|
const famSlug = fam?.slug || user.famId;
|
||||||
|
|
||||||
|
// Parents (admins) land on the fam dashboard — no username in the URL.
|
||||||
|
if (user.role === 'parent') {
|
||||||
|
throw redirect(303, `/${famSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children don't log in via email; this is just a safe fallback.
|
||||||
|
const handle = handleOf(user.username || '') || user.name || email.split('@')[0];
|
||||||
|
throw redirect(303, `/${famSlug}/${handle}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { clearSession } from '$lib/server/auth';
|
import { clearSessionCookie } from '$lib/server/session';
|
||||||
|
|
||||||
|
function signOut(event: { cookies: any }) {
|
||||||
|
clearSessionCookie(event.cookies);
|
||||||
|
event.cookies.delete('session', { path: '/' });
|
||||||
|
event.cookies.delete('device_token', { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
export function load(event) {
|
export function load(event) {
|
||||||
clearSession(event);
|
signOut(event);
|
||||||
throw redirect(303, '/');
|
throw redirect(303, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: (event) => {
|
default: (event) => {
|
||||||
clearSession(event);
|
signOut(event);
|
||||||
throw redirect(303, '/');
|
throw redirect(303, '/');
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -1,35 +1,94 @@
|
|||||||
import { fail, isRedirect, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { setSessionCookie } from '$lib/server/session';
|
||||||
|
import { issueAccess } from '$lib/server/member-otp';
|
||||||
|
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
|
||||||
|
|
||||||
|
class SignupError extends Error {}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
// Step 1 — create the family + parent (admin) user, mint their session.
|
||||||
|
// The parent's human-entered name is kept as the display `name`; their PB
|
||||||
|
// `username` is `{famSlug}:{handle}` (globally unique, handle = no whitespace).
|
||||||
|
signup: async (event) => {
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
|
const famName = fd.get('familyName') as string;
|
||||||
|
const yourName = (fd.get('yourName') as string) || '';
|
||||||
const email = fd.get('email') as string;
|
const email = fd.get('email') as string;
|
||||||
const password = fd.get('password') as string;
|
const password = fd.get('password') as string;
|
||||||
const famName = fd.get('famName') as string;
|
|
||||||
const parentName = fd.get('parentName') as string;
|
|
||||||
|
|
||||||
if (!email || !password || !famName) {
|
if (!famName || !yourName || !email || !password) {
|
||||||
return fail(400, { error: 'All fields required', email, famName });
|
return fail(400, { message: 'All fields required', famName, email });
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return fail(400, { error: 'Password must be at least 8 characters', email, famName });
|
return fail(400, { message: 'Password must be at least 8 characters', famName, email });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentName = yourName.trim();
|
||||||
|
const slug = slugify(famName);
|
||||||
|
const handleName = handle(parentName) || 'admin';
|
||||||
|
const username = famUsername(slug, handleName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signup(email, password, famName, parentName || email.split('@')[0]);
|
const fam = await pbAdmin.create('fams', {
|
||||||
setSessionCookie(event, {
|
name: famName,
|
||||||
famId: result.famId,
|
slug,
|
||||||
userId: result.userId,
|
timezone: 'auto'
|
||||||
famSlug: result.famSlug,
|
|
||||||
memberName: result.memberName,
|
|
||||||
role: result.role,
|
|
||||||
});
|
});
|
||||||
setPbTokenCookie(event, result.token);
|
const user = await pbAdmin.create('users', {
|
||||||
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
|
username,
|
||||||
|
name: parentName,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
passwordConfirm: password,
|
||||||
|
emailVisibility: false,
|
||||||
|
famId: fam.id,
|
||||||
|
role: 'parent'
|
||||||
|
});
|
||||||
|
await pbAdmin.create('settings', { famId: fam.id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isRedirect(e)) throw e;
|
throw new SignupError(
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName });
|
`Could not create account — ${e instanceof Error ? e.message : 'please try again'}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth as the new parent to mint their JWT, then move to the child step.
|
||||||
|
const authResult = await createPbClient()
|
||||||
|
.collection('users')
|
||||||
|
.authWithPassword(email, password)
|
||||||
|
.catch(() => null);
|
||||||
|
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
|
||||||
|
|
||||||
|
// `username` here is the handle (URL segment), not the composite.
|
||||||
|
return { success: true, famSlug: slug, username: handleName };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Step 2 — optionally add a child now; issues their OTP join code.
|
||||||
|
child: async (event: RequestEvent) => {
|
||||||
|
const user = requireUser(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const name = ((fd.get('member') as string) || '').trim();
|
||||||
|
|
||||||
|
const fam = await pbAdmin.getOne('fams', user.famId);
|
||||||
|
const famSlug = fam?.slug || user.famId;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return { success: true, famSlug, username: handleOf(user.username) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { otp, joinUrl } = await issueAccess({
|
||||||
|
famId: user.famId,
|
||||||
|
famSlug,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function requireUser(event: RequestEvent) {
|
||||||
|
if (!event.locals.user) throw redirect(303, '/signup');
|
||||||
|
return event.locals.user;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,48 +1,124 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import AuthShell from '$lib/components/AuthShell.svelte';
|
import AuthShell from '$lib/components/AuthShell.svelte';
|
||||||
|
import { slugify, handle } from '@shared/slugify';
|
||||||
|
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
|
|
||||||
|
let step = $state(1);
|
||||||
|
let famName = $state('');
|
||||||
|
let yourName = $state('');
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let famName = $state('');
|
let childName = $state('');
|
||||||
let parentName = $state('');
|
let submitting = $state(false);
|
||||||
|
let localError = $state('');
|
||||||
|
|
||||||
|
let famSlugPreview = $derived(slugify(famName) || 'your-family');
|
||||||
|
let handlePreview = $derived(handle(yourName) || 'your-name');
|
||||||
|
|
||||||
|
const enhanceForm = () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- canary $types lacks SubmitFunction
|
||||||
|
return () =>
|
||||||
|
async ({ update, result }: any) => {
|
||||||
|
submitting = true;
|
||||||
|
localError = '';
|
||||||
|
try {
|
||||||
|
await update();
|
||||||
|
} catch (e) {
|
||||||
|
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
submitting = false;
|
||||||
|
if (result.type !== 'failure' && result.type !== 'error') step++;
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
||||||
{#if form?.error}
|
{#if form?.message}
|
||||||
<p class="form-error">{form.error}</p>
|
<p class="form-error">{form.message}</p>
|
||||||
|
{/if}
|
||||||
|
{#if localError}
|
||||||
|
<p class="form-error">{localError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST">
|
{#if step === 1}
|
||||||
<label>
|
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
|
||||||
Family name
|
<label>
|
||||||
<input name="famName" bind:value={famName} placeholder="The Smiths" required />
|
Family name
|
||||||
</label>
|
<input name="familyName" bind:value={famName} placeholder="The Smiths" required />
|
||||||
<label>
|
{#if famName}
|
||||||
Your name
|
<span class="preview">Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code></span>
|
||||||
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Email
|
Your name
|
||||||
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
|
<input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
|
||||||
</label>
|
{#if yourName}
|
||||||
<label>
|
<span class="preview">
|
||||||
Password
|
Your address: <code>/</code><code class="inline-code">{famSlugPreview}/{handlePreview}</code>
|
||||||
<input
|
<small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
|
||||||
type="password"
|
</span>
|
||||||
name="password"
|
{:else}
|
||||||
bind:value={password}
|
<span class="preview-hint">No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span>
|
||||||
placeholder="8+ characters"
|
{/if}
|
||||||
minlength={8}
|
</label>
|
||||||
required
|
<label>
|
||||||
/>
|
Email
|
||||||
</label>
|
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
|
||||||
<button type="submit">Create my family</button>
|
</label>
|
||||||
</form>
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="8+ characters"
|
||||||
|
minlength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={submitting}>Create my family</button>
|
||||||
|
</form>
|
||||||
|
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<p class="alt">
|
{#if step === 2}
|
||||||
Already have a family? <a href="/login">Log in</a>
|
<h3 class="step-title">Add a child now?</h3>
|
||||||
</p>
|
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
|
||||||
|
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
|
||||||
|
<label>
|
||||||
|
Child's name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="member"
|
||||||
|
bind:value={childName}
|
||||||
|
placeholder="Their first name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={submitting}>Create join code</button>
|
||||||
|
</form>
|
||||||
|
<p class="alt">
|
||||||
|
<a href="/{form?.famSlug}">Skip for now →</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if step === 3}
|
||||||
|
<h3 class="step-title">{childName ? `Nice — share this code with ${childName}:` : 'Your family is ready!'}</h3>
|
||||||
|
{#if form?.code}
|
||||||
|
<div class="code">
|
||||||
|
<span class="code-text">{form.code}</span>
|
||||||
|
</div>
|
||||||
|
<p class="step-note">
|
||||||
|
They open <code class="inline-code">{form?.joinUrl}</code> and enter this code.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/{form?.famSlug}" class="btn-primary">Go to dashboard</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -81,6 +157,17 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:hover { background: #3730a3; }
|
button:hover { background: #3730a3; }
|
||||||
|
button:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
.step-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.step-note {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
.form-error {
|
.form-error {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
@@ -90,6 +177,23 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
.preview {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.preview-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.preview .inline-code, .preview-hint .inline-code {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
.alt {
|
.alt {
|
||||||
margin: 1.25rem 0 0;
|
margin: 1.25rem 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -97,4 +201,42 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
||||||
</style>
|
.code {
|
||||||
|
background: #eef2ff;
|
||||||
|
border: 1px dashed #a5b4fc;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.code-text {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.inline-code {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #3730a3; }
|
||||||
|
</style>
|
||||||
@@ -29,15 +29,8 @@ export default defineConfig(() => {
|
|||||||
allow: ['.', './node_modules', '../node_modules']
|
allow: ['.', './node_modules', '../node_modules']
|
||||||
},
|
},
|
||||||
// Dev only: allow access via any host/LAN IP without hardcoding it.
|
// Dev only: allow access via any host/LAN IP without hardcoding it.
|
||||||
allowedHosts: true,
|
allowedHosts: true as true,
|
||||||
port: 2080,
|
port: 2080
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
// The vite dev server and Hono proxy run on the same host.
|
|
||||||
target: `http://127.0.0.1:3456`,
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -3,14 +3,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.30.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev",
|
"dev": "pnpm --filter frontend dev",
|
||||||
"start": "pnpm dev",
|
"start": "pnpm dev",
|
||||||
"build": "pnpm -r build"
|
"build": "pnpm --filter frontend build"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": "1.1.0"
|
"version": "1.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-45
@@ -64,28 +64,6 @@ importers:
|
|||||||
specifier: 8.0.16
|
specifier: 8.0.16
|
||||||
version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||||
|
|
||||||
proxy:
|
|
||||||
dependencies:
|
|
||||||
'@hono/node-server':
|
|
||||||
specifier: ^1.13.0
|
|
||||||
version: 1.19.14(hono@4.12.27)
|
|
||||||
hono:
|
|
||||||
specifier: ^4.7.0
|
|
||||||
version: 4.12.27
|
|
||||||
devDependencies:
|
|
||||||
'@types/node':
|
|
||||||
specifier: ^26.0.0
|
|
||||||
version: 26.0.0
|
|
||||||
esbuild:
|
|
||||||
specifier: ^0.28.1
|
|
||||||
version: 0.28.1
|
|
||||||
tsx:
|
|
||||||
specifier: ^4.19.0
|
|
||||||
version: 4.22.4
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.7.0
|
|
||||||
version: 5.9.3
|
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
@@ -265,12 +243,6 @@ packages:
|
|||||||
'@hiseb/confetti@2.2.0':
|
'@hiseb/confetti@2.2.0':
|
||||||
resolution: {integrity: sha512-iCcTe2AS2Mnj7f2BGPnOetjnX+Qs1jgnKU0GSYyQHFB42psio0EpgxmPXOXf2wcCGBH/W+1G2Ecl4hcbsin1Kg==}
|
resolution: {integrity: sha512-iCcTe2AS2Mnj7f2BGPnOetjnX+Qs1jgnKU0GSYyQHFB42psio0EpgxmPXOXf2wcCGBH/W+1G2Ecl4hcbsin1Kg==}
|
||||||
|
|
||||||
'@hono/node-server@1.19.14':
|
|
||||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
|
||||||
engines: {node: '>=18.14.1'}
|
|
||||||
peerDependencies:
|
|
||||||
hono: ^4
|
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -761,10 +733,6 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
hono@4.12.27:
|
|
||||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
|
||||||
engines: {node: '>=16.9.0'}
|
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0:
|
is-fullwidth-code-point@3.0.0:
|
||||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1062,11 +1030,6 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
typescript@5.9.3:
|
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
|
||||||
engines: {node: '>=14.17'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
typescript@6.0.3:
|
typescript@6.0.3:
|
||||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1261,10 +1224,6 @@ snapshots:
|
|||||||
|
|
||||||
'@hiseb/confetti@2.2.0': {}
|
'@hiseb/confetti@2.2.0': {}
|
||||||
|
|
||||||
'@hono/node-server@1.19.14(hono@4.12.27)':
|
|
||||||
dependencies:
|
|
||||||
hono: 4.12.27
|
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1609,6 +1568,7 @@ snapshots:
|
|||||||
'@esbuild/win32-arm64': 0.28.1
|
'@esbuild/win32-arm64': 0.28.1
|
||||||
'@esbuild/win32-ia32': 0.28.1
|
'@esbuild/win32-ia32': 0.28.1
|
||||||
'@esbuild/win32-x64': 0.28.1
|
'@esbuild/win32-x64': 0.28.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
esm-env@1.2.2: {}
|
esm-env@1.2.2: {}
|
||||||
|
|
||||||
@@ -1632,8 +1592,6 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
hono@4.12.27: {}
|
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
is-reference@3.0.3:
|
is-reference@3.0.3:
|
||||||
@@ -1879,8 +1837,7 @@ snapshots:
|
|||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
optional: true
|
||||||
typescript@5.9.3: {}
|
|
||||||
|
|
||||||
typescript@6.0.3: {}
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
injectWorkspacePackages: true
|
injectWorkspacePackages: true
|
||||||
packages:
|
packages:
|
||||||
- "frontend"
|
- "frontend"
|
||||||
- "proxy"
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- "@tailwindcss/oxide"
|
- "@tailwindcss/oxide"
|
||||||
- esbuild
|
- esbuild
|
||||||
@@ -1 +0,0 @@
|
|||||||
/dist
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "proxy",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx watch --env-file-if-exists=../.env src/index.ts",
|
|
||||||
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --alias:@shared=../shared",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"seed": "tsx --env-file-if-exists=../.env scripts/seed.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@hono/node-server": "^1.13.0",
|
|
||||||
"hono": "^4.7.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^26.0.0",
|
|
||||||
"esbuild": "^0.28.1",
|
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import {
|
|
||||||
SCHEMA_PLAN,
|
|
||||||
type CollectionDef,
|
|
||||||
} from "@shared/pb/schema.ts";
|
|
||||||
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "../src/env.ts";
|
|
||||||
|
|
||||||
async function getSuperadminToken(): Promise<string> {
|
|
||||||
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<string | null> {
|
|
||||||
const existing = await fetch(
|
|
||||||
`${PB_ENDPOINT}/api/collections?filter=name='${col.name}'`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
const existingData = await existing.json();
|
|
||||||
if (existingData?.items?.length > 0) {
|
|
||||||
console.log(` ↳ Already exists: ${col.name}`);
|
|
||||||
return existingData.items[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(col),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
|
|
||||||
console.log(` ✓ Created: ${col.name}`);
|
|
||||||
return data.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Connecting to PB at", PB_ENDPOINT);
|
|
||||||
const token = await getSuperadminToken();
|
|
||||||
console.log("Authenticated as superadmin\n");
|
|
||||||
|
|
||||||
const ids: Record<string, string> = {};
|
|
||||||
for (const entry of SCHEMA_PLAN) {
|
|
||||||
const id = await createCollection(token, entry.build(ids));
|
|
||||||
if (id) ids[entry.name] = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n✅ All collections created successfully");
|
|
||||||
console.log("Collection IDs:", ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Seed failed:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
const HONO = "http://192.168.1.225:3456";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Signup
|
|
||||||
const signup = await fetch(`${HONO}/api/admin/signup`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: "admin2@test.com", password: "password1234", famName: "Admin Test" }),
|
|
||||||
});
|
|
||||||
const s = await signup.json();
|
|
||||||
console.log("Signup:", s.famId, s.famSlug);
|
|
||||||
const famId = s.famId;
|
|
||||||
|
|
||||||
// Test admin authenticated calls
|
|
||||||
const headers = {
|
|
||||||
"x-session-famid": famId,
|
|
||||||
"x-session-role": "admin",
|
|
||||||
"x-session-userid": s.userId,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a chore template
|
|
||||||
console.log("\nCreate template...");
|
|
||||||
const tmpl = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, {
|
|
||||||
method: "POST", headers, body: JSON.stringify({
|
|
||||||
name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 10,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const t = await tmpl.json();
|
|
||||||
console.log("Template:", t.id, t.name);
|
|
||||||
|
|
||||||
// List templates
|
|
||||||
console.log("\nList templates...");
|
|
||||||
const list = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { headers });
|
|
||||||
console.log("Templates:", (await list.json()).length);
|
|
||||||
|
|
||||||
// Create a member
|
|
||||||
console.log("\nCreate member...");
|
|
||||||
const mem = await fetch(`${HONO}/api/admin/${famId}/members`, {
|
|
||||||
method: "POST", headers, body: JSON.stringify({ name: "Kid", color: "#6366f1" }),
|
|
||||||
});
|
|
||||||
const m = await mem.json();
|
|
||||||
console.log("Member:", m.id, m.name);
|
|
||||||
|
|
||||||
// Assign chore
|
|
||||||
console.log("\nAssign chore...");
|
|
||||||
const assign = await fetch(`${HONO}/api/admin/${famId}/assigned-chores`, {
|
|
||||||
method: "POST", headers, body: JSON.stringify({
|
|
||||||
memberId: m.id, templateId: t.id, frequency: "daily", type: "points", value: 10,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const a = await assign.json();
|
|
||||||
console.log("Assigned:", a.id);
|
|
||||||
|
|
||||||
// Test device token auth
|
|
||||||
console.log("\nTest completion toggle with device token...");
|
|
||||||
const devHeaders = {
|
|
||||||
"x-device-token": "dev-token-test",
|
|
||||||
"x-device-famid": famId,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
// First need to join with this device token
|
|
||||||
const join = await fetch(`${HONO}/api/members/join`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ inviteCode: s.famSlug, ...(await (await fetch(`${HONO}/api/admin/${famId}/members`, { headers })).json()).length > 1 ? {} : { name: "Test", deviceToken: "dev-token-test" } }),
|
|
||||||
});
|
|
||||||
// Actually, just use the member we already created but we can't use device token with it since it has no token
|
|
||||||
// Let's join a new one
|
|
||||||
console.log("Joining with device token...");
|
|
||||||
const j = await fetch(`${HONO}/api/members/join`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ inviteCode: "TEST", name: "Test Kid", deviceToken: "dev-token-test" }),
|
|
||||||
});
|
|
||||||
const joinData = await j.json();
|
|
||||||
console.log("Join result:", JSON.stringify(joinData));
|
|
||||||
|
|
||||||
if (joinData.famId) {
|
|
||||||
// Toggle completion
|
|
||||||
console.log("\nToggle completion...");
|
|
||||||
const toggle = await fetch(`${HONO}/api/completions/toggle`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }),
|
|
||||||
});
|
|
||||||
console.log("Toggle:", await toggle.json());
|
|
||||||
|
|
||||||
// Toggle again (should undo)
|
|
||||||
const toggle2 = await fetch(`${HONO}/api/completions/toggle`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }),
|
|
||||||
});
|
|
||||||
console.log("Toggle undo:", await toggle2.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n✅ All admin tests passed");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
const HONO = "http://192.168.1.225:3456";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// 1. Signup
|
|
||||||
console.log("=== Signup ===");
|
|
||||||
const signup = await fetch(`${HONO}/api/admin/signup`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: "test@fam.com", password: "password123", famName: "Test Fam" }),
|
|
||||||
});
|
|
||||||
const signupData = await signup.json();
|
|
||||||
console.log("Signup:", JSON.stringify(signupData, null, 2));
|
|
||||||
|
|
||||||
// 2. Login
|
|
||||||
console.log("\n=== Login ===");
|
|
||||||
const login = await fetch(`${HONO}/api/admin/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: "test@fam.com", password: "password123" }),
|
|
||||||
});
|
|
||||||
const loginData = await login.json();
|
|
||||||
console.log("Login:", JSON.stringify(loginData, null, 2));
|
|
||||||
|
|
||||||
// 3. Get invite code from fams directly via PB
|
|
||||||
const pbTokenRes = await fetch("http://192.168.1.225:8090/api/collections/_superusers/auth-with-password", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ identity: "debug@famchamp.dev", password: "debug123" }),
|
|
||||||
});
|
|
||||||
const pbTokenData = await pbTokenRes.json();
|
|
||||||
const pbSuperToken = pbTokenData.token;
|
|
||||||
|
|
||||||
const famsRes = await fetch("http://192.168.1.225:8090/api/collections/fams/records?sort=-created", {
|
|
||||||
headers: { Authorization: `Bearer ${pbSuperToken}` },
|
|
||||||
});
|
|
||||||
const famsData = await famsRes.json();
|
|
||||||
const fam = famsData.items[0];
|
|
||||||
console.log("\n=== Fam ===");
|
|
||||||
console.log("Fam:", JSON.stringify(fam, null, 2));
|
|
||||||
console.log("Invite code:", fam.inviteCode);
|
|
||||||
|
|
||||||
// 4. Join as member
|
|
||||||
console.log("\n=== Join ===");
|
|
||||||
const join = await fetch(`${HONO}/api/members/join`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ inviteCode: fam.inviteCode, name: "Kid", deviceToken: "dev-token-xyz" }),
|
|
||||||
});
|
|
||||||
const joinData = await join.json();
|
|
||||||
console.log("Join:", JSON.stringify(joinData, null, 2));
|
|
||||||
|
|
||||||
// 5. Verify member
|
|
||||||
console.log("\n=== Verify ===");
|
|
||||||
const verify = await fetch(`${HONO}/api/members/verify`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ famId: fam.id, deviceToken: "dev-token-xyz" }),
|
|
||||||
});
|
|
||||||
const verifyData = await verify.json();
|
|
||||||
console.log("Verify:", JSON.stringify(verifyData, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// Runtime env for the proxy. Same dev/prod split as the frontend: dev connects
|
|
||||||
// to the dev machine's PocketBase at SERVER_IP, prod connects to the
|
|
||||||
// container-internal loopback PB. Ports come from config.ts (single source).
|
|
||||||
//
|
|
||||||
// Env loading: dev uses `tsx --env-file=../.env` (see package.json) like vite
|
|
||||||
// does for the frontend; prod (docker) injects env via compose and has no .env,
|
|
||||||
// so SERVER_IP is unset → loopback below.
|
|
||||||
const SERVER_IP = process.env.SERVER_IP;
|
|
||||||
|
|
||||||
// PB_ENDPOINT can be overridden explicitly (used for migration step-through
|
|
||||||
// against a throwaway PB on another port). Defaults to the dev/prod split.
|
|
||||||
export const PB_ENDPOINT =
|
|
||||||
process.env.PB_ENDPOINT ||
|
|
||||||
(SERVER_IP ? `http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`);
|
|
||||||
export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev";
|
|
||||||
export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123";
|
|
||||||
-2447
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,92 +0,0 @@
|
|||||||
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts";
|
|
||||||
|
|
||||||
let adminToken: string | null = null;
|
|
||||||
let tokenExpiry = 0;
|
|
||||||
|
|
||||||
async function ensureToken(): Promise<string> {
|
|
||||||
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<Response> {
|
|
||||||
const token = await ensureToken();
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
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<string, unknown>) {
|
|
||||||
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<string, unknown>) {
|
|
||||||
const res = await request("PATCH", `/api/collections/${collection}/records/${id}`, data);
|
|
||||||
const json = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB update ${collection}: ${JSON.stringify(json)}`);
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(collection: string, id: string) {
|
|
||||||
const res = await request("DELETE", `/api/collections/${collection}/records/${id}`);
|
|
||||||
const body = await res.text();
|
|
||||||
if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status} ${body}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getList(collection: string, filter = "") {
|
|
||||||
let path = `/api/collections/${collection}/records?perPage=1000`;
|
|
||||||
if (filter) path += `&filter=${encodeURIComponent(filter)}`;
|
|
||||||
const res = await request("GET", path);
|
|
||||||
const json = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`);
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
|
|
||||||
async authWithPassword(identity: string, password: string) {
|
|
||||||
const res = await fetch(
|
|
||||||
`${PB_ENDPOINT}/api/collections/users/auth-with-password`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ identity, password }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const json = await res.json();
|
|
||||||
if (!res.ok) throw new Error(`PB auth: ${JSON.stringify(json)}`);
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createUser(email: string, password: string) {
|
|
||||||
return pb.create("users", {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
passwordConfirm: password,
|
|
||||||
emailVisibility: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"paths": {
|
|
||||||
"@shared/*": ["../shared/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*", "../shared/**/*"]
|
|
||||||
}
|
|
||||||
+3
-4
@@ -1,5 +1,4 @@
|
|||||||
// Single source of truth for the three service ports (dev/build-time only,
|
// Single source of truth for service ports (dev/build-time only). Runtime URLs
|
||||||
// used by the Hono proxy). Runtime URLs are set via env (see proxy/src/env.ts).
|
// come from env (see frontend/src/env.ts).
|
||||||
export const FRONTEND_PORT = "2080";
|
export const FRONTEND_PORT = "2080";
|
||||||
export const PROXY_PORT = "3456";
|
export const PB_PORT = "8090";
|
||||||
export const PB_PORT = "8090";
|
|
||||||
+103
-47
@@ -1,9 +1,14 @@
|
|||||||
// Single source of truth for the PocketBase schema + field builders.
|
// Single source of truth for the PocketBase schema + field builders.
|
||||||
// Consumed by BOTH proxy/src/migrate.ts (idempotent bootstrap) and
|
// Consumed by frontend/src/lib/server/migrate.ts (idempotent bootstrap) so the
|
||||||
// proxy/scripts/seed.ts (fresh-store seed) so the schema isn't duplicated.
|
// schema isn't duplicated.
|
||||||
//
|
//
|
||||||
// Relations reference collections by name; the `ids` map maps collection
|
// Relations reference collections by name; the `ids` map maps collection
|
||||||
// name -> runtime id (filled as each collection is created).
|
// name -> runtime id (filled as each collection is created).
|
||||||
|
//
|
||||||
|
// NOTE: the native `users` auth collection and the superuser-only `otp`
|
||||||
|
// collection are NOT in SCHEMA_PLAN — they're applied separately in
|
||||||
|
// migrate.ts (users is PB's built-in auth model; `otp` needs null rules,
|
||||||
|
// which the `col()` builder can't express). Everything else lives here.
|
||||||
|
|
||||||
export interface FieldDef {
|
export interface FieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -69,20 +74,43 @@ export function rel(name: string, collectionId: string, required = false): Field
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-user access rules ──
|
||||||
|
// The app writes directly to PB as the authenticated user (their own token),
|
||||||
|
// so PB enforces famId scoping instead of running everything as superuser.
|
||||||
|
// Only genuinely privileged app-level actions (signup, OTP join, member
|
||||||
|
// creation, superuser dashboard, CRON/webhook) use the superuser client.
|
||||||
|
//
|
||||||
|
// Admin-only collections require role='parent'; child-accessible collections
|
||||||
|
// (completions toggle, reward claim, chat) are scoped by famId alone.
|
||||||
|
export const RULE_PARENT_WRITE =
|
||||||
|
"@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'";
|
||||||
|
export const RULE_PARENT_SCOPED =
|
||||||
|
"famId = @request.auth.famId && @request.auth.role = 'parent'";
|
||||||
|
export const RULE_FAM_WRITE = "@request.body.famId = @request.auth.famId";
|
||||||
|
export const RULE_FAM_SCOPED = "famId = @request.auth.famId";
|
||||||
|
// fams has no self-referencing famId field; its record id IS the famId.
|
||||||
|
export const RULE_OWN_FAM = "id = @request.auth.famId";
|
||||||
|
|
||||||
// ── Collection builder ──
|
// ── Collection builder ──
|
||||||
export function col(
|
export function col(
|
||||||
name: string,
|
name: string,
|
||||||
fields: FieldDef[],
|
fields: FieldDef[],
|
||||||
rules: { listRule?: string | null; viewRule?: string | null } = {},
|
rules: {
|
||||||
|
listRule?: string | null;
|
||||||
|
viewRule?: string | null;
|
||||||
|
createRule?: string | null;
|
||||||
|
updateRule?: string | null;
|
||||||
|
deleteRule?: string | null;
|
||||||
|
} = {},
|
||||||
): (ids: Record<string, string>) => CollectionDef {
|
): (ids: Record<string, string>) => CollectionDef {
|
||||||
return (ids) => ({
|
return (ids) => ({
|
||||||
name,
|
name,
|
||||||
type: "base",
|
type: "base",
|
||||||
listRule: rules.listRule ?? "",
|
listRule: rules.listRule ?? "",
|
||||||
viewRule: rules.viewRule ?? "",
|
viewRule: rules.viewRule ?? "",
|
||||||
createRule: null,
|
createRule: rules.createRule ?? null,
|
||||||
updateRule: null,
|
updateRule: rules.updateRule ?? null,
|
||||||
deleteRule: null,
|
deleteRule: rules.deleteRule ?? null,
|
||||||
fields,
|
fields,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,12 +131,14 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
[
|
[
|
||||||
text("name", true),
|
text("name", true),
|
||||||
uniqueText("slug"),
|
uniqueText("slug"),
|
||||||
text("inviteCode"),
|
|
||||||
text("stripeCustomerId"),
|
text("stripeCustomerId"),
|
||||||
jsonField("featureFlags"),
|
jsonField("featureFlags"),
|
||||||
jsonField("seasons"),
|
number("payday"),
|
||||||
|
text("lastIssued"),
|
||||||
|
text("paydayTime"),
|
||||||
|
text("timezone"),
|
||||||
],
|
],
|
||||||
{ listRule: null, viewRule: null },
|
{ listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM },
|
||||||
)(ids),
|
)(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,23 +155,20 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
text("rewardValue", true),
|
text("rewardValue", true),
|
||||||
number("criteriaValue"),
|
number("criteriaValue"),
|
||||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_PARENT_WRITE,
|
||||||
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "settings",
|
name: "settings",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")])(ids),
|
col("settings", [rel("famId", ids.fams, true), text("webhookUrl"), bool("simulateEow")], {
|
||||||
},
|
createRule: RULE_PARENT_WRITE,
|
||||||
{
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
name: "members",
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
build: (ids) =>
|
})(ids),
|
||||||
col("members", [
|
|
||||||
rel("famId", ids.fams, true),
|
|
||||||
text("name", true),
|
|
||||||
text("color"),
|
|
||||||
text("deviceToken"),
|
|
||||||
text("deviceTokenHint"),
|
|
||||||
])(ids),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chore_templates",
|
name: "chore_templates",
|
||||||
@@ -153,18 +180,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
select("defaultFrequency", ["daily", "weekly"], true),
|
select("defaultFrequency", ["daily", "weekly"], true),
|
||||||
select("defaultType", ["points", "money"], true),
|
select("defaultType", ["points", "money"], true),
|
||||||
number("defaultValue", true),
|
number("defaultValue", true),
|
||||||
])(ids),
|
], {
|
||||||
},
|
createRule: RULE_PARENT_WRITE,
|
||||||
{
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
name: "fam_admins",
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
build: (ids) =>
|
})(ids),
|
||||||
col("fam_admins", [
|
|
||||||
rel("famId", ids.fams, true),
|
|
||||||
text("userId", true),
|
|
||||||
text("email", true),
|
|
||||||
text("name"),
|
|
||||||
text("color"),
|
|
||||||
])(ids),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bonus_configs",
|
name: "bonus_configs",
|
||||||
@@ -179,23 +199,31 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
select("rewardType", ["points", "cash", "prize"], true),
|
select("rewardType", ["points", "cash", "prize"], true),
|
||||||
text("rewardValue", true),
|
text("rewardValue", true),
|
||||||
number("criteriaValue"),
|
number("criteriaValue"),
|
||||||
rel("memberId", ids.members),
|
rel("memberId", ids.users),
|
||||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||||
select("status", ["active", "completed"], true),
|
select("status", ["active", "completed"], true),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_PARENT_WRITE,
|
||||||
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "weekly_history",
|
name: "weekly_history",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("weekly_history", [
|
col("weekly_history", [
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.members, true),
|
rel("memberId", ids.users, true),
|
||||||
date("weekStart"),
|
date("weekStart"),
|
||||||
number("pointsEarned"),
|
number("pointsEarned"),
|
||||||
number("moneyEarned"),
|
number("moneyEarned"),
|
||||||
number("choresCompleted"),
|
number("choresCompleted"),
|
||||||
number("bonusEarned"),
|
number("bonusEarned"),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_PARENT_WRITE,
|
||||||
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "seasons",
|
name: "seasons",
|
||||||
@@ -207,7 +235,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
bool("active"),
|
bool("active"),
|
||||||
date("autoDisable"),
|
date("autoDisable"),
|
||||||
date("autoStart"),
|
date("autoStart"),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_PARENT_WRITE,
|
||||||
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "messages",
|
name: "messages",
|
||||||
@@ -222,7 +254,12 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
// Explicit createdAt: PB 0.39 does NOT auto-add createdAt to
|
// Explicit createdAt: PB 0.39 does NOT auto-add createdAt to
|
||||||
// API-created collections (0.25 did). Chat filters/sorts on it.
|
// API-created collections (0.25 did). Chat filters/sorts on it.
|
||||||
date("createdAt"),
|
date("createdAt"),
|
||||||
])(ids),
|
text("clientId"),
|
||||||
|
], {
|
||||||
|
createRule: RULE_FAM_WRITE,
|
||||||
|
updateRule: RULE_FAM_SCOPED,
|
||||||
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chat_typing",
|
name: "chat_typing",
|
||||||
@@ -234,14 +271,18 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
text("authorName", true),
|
text("authorName", true),
|
||||||
text("authorColor"),
|
text("authorColor"),
|
||||||
bool("typing"),
|
bool("typing"),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_FAM_WRITE,
|
||||||
|
updateRule: RULE_FAM_SCOPED,
|
||||||
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "rewards",
|
name: "rewards",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("rewards", [
|
col("rewards", [
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.members, true),
|
rel("memberId", ids.users, true),
|
||||||
rel("bonusConfigId", ids.bonus_configs),
|
rel("bonusConfigId", ids.bonus_configs),
|
||||||
text("label", true),
|
text("label", true),
|
||||||
number("value", true),
|
number("value", true),
|
||||||
@@ -252,31 +293,46 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
date("claimedAt"),
|
date("claimedAt"),
|
||||||
date("requestedAt"),
|
date("requestedAt"),
|
||||||
text("date"),
|
text("date"),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_FAM_WRITE,
|
||||||
|
updateRule: RULE_FAM_SCOPED,
|
||||||
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "assigned_chores",
|
name: "assigned_chores",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("assigned_chores", [
|
col("assigned_chores", [
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.members, true),
|
rel("memberId", ids.users, true),
|
||||||
rel("templateId", ids.chore_templates, true),
|
rel("templateId", ids.chore_templates, true),
|
||||||
select("frequency", ["daily", "weekly"], true),
|
select("frequency", ["daily", "weekly"], true),
|
||||||
select("type", ["points", "money"], true),
|
select("type", ["points", "money"], true),
|
||||||
number("value", true),
|
number("value", true),
|
||||||
text("customName"),
|
text("customName"),
|
||||||
jsonField("seasonIds"),
|
jsonField("seasonIds"),
|
||||||
])(ids),
|
bool("isTodo"),
|
||||||
|
text("startDate"),
|
||||||
|
text("completeBy"),
|
||||||
|
], {
|
||||||
|
createRule: RULE_PARENT_WRITE,
|
||||||
|
updateRule: RULE_PARENT_SCOPED,
|
||||||
|
deleteRule: RULE_PARENT_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "completions",
|
name: "completions",
|
||||||
build: (ids) =>
|
build: (ids) =>
|
||||||
col("completions", [
|
col("completions", [
|
||||||
rel("famId", ids.fams, true),
|
rel("famId", ids.fams, true),
|
||||||
rel("memberId", ids.members, true),
|
rel("memberId", ids.users, true),
|
||||||
rel("assignedChoreId", ids.assigned_chores, true),
|
rel("assignedChoreId", ids.assigned_chores, true),
|
||||||
date("date"),
|
date("date"),
|
||||||
date("completedAt"),
|
date("completedAt"),
|
||||||
])(ids),
|
], {
|
||||||
|
createRule: RULE_FAM_WRITE,
|
||||||
|
updateRule: RULE_FAM_SCOPED,
|
||||||
|
deleteRule: RULE_FAM_SCOPED,
|
||||||
|
})(ids),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Minimal signed-token helper (HMAC-SHA256, JWT-ish but not JWT) shared by the
|
||||||
|
// Hono proxy (issues + verifies) and the SvelteKit frontend (verifies locally
|
||||||
|
// so hooks.server.ts doesn't need a round-trip to the proxy on every request).
|
||||||
|
//
|
||||||
|
// The proxy independently re-verifies the same token on every write, so the
|
||||||
|
// frontend's local verification is a performance optimization, not the
|
||||||
|
// security boundary — the boundary is the proxy.
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
// Dev-only fallback secrets. Both sides must derive the same default when the
|
||||||
|
// real env var isn't set, so local dev works without extra setup. Production
|
||||||
|
// MUST set SESSION_TOKEN_SECRET / PLATFORM_TOKEN_SECRET to a strong value.
|
||||||
|
export const DEV_SESSION_TOKEN_SECRET = "famchamp-dev-session-secret-change-me";
|
||||||
|
export const DEV_PLATFORM_TOKEN_SECRET = "famchamp-dev-platform-secret-change-me";
|
||||||
|
|
||||||
|
export type TokenPayload = Record<string, unknown> & { exp: number };
|
||||||
|
|
||||||
|
function encode(json: string): string {
|
||||||
|
return Buffer.from(json).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(secret: string, encodedPayload: string): string {
|
||||||
|
return crypto.createHmac("sha256", secret).update(encodedPayload).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs `payload` (plus an `exp` computed from `ttlMs`) into an opaque token string. */
|
||||||
|
export function issueToken<T extends Record<string, unknown>>(
|
||||||
|
secret: string,
|
||||||
|
payload: T,
|
||||||
|
ttlMs: number,
|
||||||
|
): string {
|
||||||
|
const encoded = encode(JSON.stringify({ ...payload, exp: Date.now() + ttlMs }));
|
||||||
|
return `${encoded}.${sign(secret, encoded)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifies signature + expiry. Returns the decoded payload, or null if invalid/expired/tampered. */
|
||||||
|
export function verifyToken<T extends TokenPayload = TokenPayload>(
|
||||||
|
secret: string,
|
||||||
|
token: string | undefined | null,
|
||||||
|
): T | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const [encoded, sig] = token.split(".");
|
||||||
|
if (!encoded || !sig) return null;
|
||||||
|
const expected = sign(secret, encoded);
|
||||||
|
const a = Buffer.from(sig);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
// Constant-time comparison — avoids leaking signature bytes via timing.
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString()) as T;
|
||||||
|
if (typeof payload.exp !== "number" || payload.exp <= Date.now()) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Name → handle / slug helpers shared by frontend + proxy.
|
||||||
|
|
||||||
|
// URL-safe family slug (hyphenated, lowercase): "The Smiths" → "the-smiths".
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace-free lowercase handle used for a member's URL segment and as the
|
||||||
|
// per-family username suffix: "Jakey Boy" → "jakeyboy", "Joe Edhook" → "joeedhook".
|
||||||
|
export function handle(name: string): string {
|
||||||
|
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member's PB username is namespaced by the family slug so it stays globally
|
||||||
|
// unique (PB requires auth-identity usernames to be unique) even though the URL
|
||||||
|
// segment is only unique within a family: "miss-fits:jakeyboy".
|
||||||
|
export function famUsername(famSlug: string, handleName: string): string {
|
||||||
|
return `${slugify(famSlug)}:${handleName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse of famUsername — the URL segment is the part after the last ":".
|
||||||
|
export function handleOf(fullUsername: string): string {
|
||||||
|
const i = fullUsername.lastIndexOf(':');
|
||||||
|
return i >= 0 ? fullUsername.slice(i + 1) : fullUsername;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user