Merge branch 'feature/migrate-hono-to-kit'
This commit is contained in:
+5
-5
@@ -1,7 +1,7 @@
|
||||
# Runtime env for the SvelteKit + Hono app.
|
||||
# Runtime env for the SvelteKit app.
|
||||
#
|
||||
# The app's own loopback URLs are constants in code (PROXY_URL / PB_ENDPOINT);
|
||||
# ports live in config.ts for the proxy. Only these are real env vars:
|
||||
# The app's own loopback URL (PB_ENDPOINT) is computed in code; only these are
|
||||
# real env vars:
|
||||
|
||||
# PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123.
|
||||
PB_EMAIL=
|
||||
@@ -9,8 +9,8 @@ PB_PASSWORD=
|
||||
# Server-only secret used to derive a child member's PB password from
|
||||
# (famSlug + username). Never expose client-side. OTP is the access gate.
|
||||
MEMBER_SECRET=
|
||||
# Public: the dev machine's IP where PB + the dev proxy run. Change this when
|
||||
# your remote IP changes — the browser (pocketbase.ts) and pb-admin read it.
|
||||
# Public: the dev machine's IP where PB runs. Change this when your remote IP
|
||||
# changes — the browser (pocketbase.ts) and server-side reads use it.
|
||||
# Prod ignores this (uses /pb via nginx). Default: 192.168.1.225.
|
||||
SERVER_IP=192.168.1.225
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
||||
|
||||
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
|
||||
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `user_configs`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**.
|
||||
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**.
|
||||
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
|
||||
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
|
||||
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
|
||||
@@ -33,7 +33,7 @@
|
||||
## PB Collections (all scoped by `famId`; child/member = `users` row)
|
||||
|
||||
- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
|
||||
- `user_configs` — famId, userId, otp, colour, updatedAt (OTP gate for child join)
|
||||
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`)
|
||||
- `fams` — name, slug, stripeCustomerId, featureFlags
|
||||
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
||||
- `assigned_chores` — famId, userId, templateId, frequency, value
|
||||
@@ -43,6 +43,8 @@
|
||||
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
|
||||
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
|
||||
|
||||
> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
||||
|
||||
## Routes
|
||||
|
||||
```
|
||||
@@ -197,7 +199,7 @@ All admin and member pages use the following pattern:
|
||||
|
||||
- Every collection query includes `famId = @request.auth.famId` filter
|
||||
- Super admin bypasses famId filter (access via PB admin API)
|
||||
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `user_configs`. No device tokens. Never log raw tokens/secrets.
|
||||
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets.
|
||||
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
|
||||
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
|
||||
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# 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)
|
||||
|
||||
### Layout Hierarchy
|
||||
|
||||
@@ -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`
|
||||
@@ -9,7 +9,6 @@ COPY . .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
RUN pnpm --filter frontend build
|
||||
RUN pnpm --filter proxy build
|
||||
|
||||
# Create a standalone production node_modules for frontend
|
||||
RUN pnpm --filter frontend deploy --prod /deploy/frontend
|
||||
@@ -29,9 +28,6 @@ COPY --from=builder /app/frontend/build ./frontend
|
||||
COPY --from=builder /deploy/frontend/node_modules ./frontend/node_modules
|
||||
COPY --from=builder /deploy/frontend/package.json ./frontend/package.json
|
||||
|
||||
# Proxy is bundled into one JS file by esbuild
|
||||
COPY --from=builder /app/proxy/dist ./proxy
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ fi
|
||||
# automigrate generates conflicting snapshots on upgraded stores.
|
||||
pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" --automigrate=false &
|
||||
|
||||
# Wait for PB to be healthy before starting the proxy (which runs migrate).
|
||||
# Wait for PB to be healthy before starting the app (which runs the schema
|
||||
# migration on boot).
|
||||
echo "[entrypoint] Waiting for PocketBase..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then
|
||||
@@ -26,10 +27,8 @@ for i in $(seq 1 30); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Start the app (frontend + proxy). The proxy auto-runs schema migration.
|
||||
# FRONTEND_PORT/PROXY_PORT are set via ENV in the Dockerfile; adapter-node
|
||||
# reads PORT, the proxy reads PROXY_PORT.
|
||||
# Start the app (SvelteKit + adapter-node). It auto-runs the schema migration.
|
||||
# PORT defaults to 3000 (adapter-node); nginx proxies to it.
|
||||
node /app/frontend/index.js &
|
||||
node /app/proxy/index.js &
|
||||
|
||||
nginx -g 'daemon off;'
|
||||
|
||||
@@ -11,16 +11,6 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# App's Hono proxy (/api/*).
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3456;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the
|
||||
# /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything
|
||||
# else under /pb/) is intentionally NOT proxied, keeping it internal.
|
||||
|
||||
@@ -10,8 +10,6 @@ const withDefault = (value: string) => ({
|
||||
} as const);
|
||||
|
||||
export const variables = defineEnvVars({
|
||||
// SSR → Hono proxy (loopback, proxy runs on the same host as SSR).
|
||||
PROXY_URL: { public: true, schema: withDefault('http://127.0.0.1:3456') },
|
||||
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
|
||||
// PB superuser creds (server-only).
|
||||
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createPbClient } from '$lib/server/pocketbase';
|
||||
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
|
||||
import type { SessionUser } from '$lib/server/types';
|
||||
import { handleOf } from '@shared/slugify';
|
||||
import { migrateOnBoot } from '$lib/server/migrate-boot';
|
||||
|
||||
// Run the PB schema migration once at server boot (idempotent).
|
||||
void migrateOnBoot();
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = null;
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// Client-only. All /api calls go same-origin (vite proxy in dev, nginx in
|
||||
// prod). Server-side (SSR) calls use PROXY_URL from $app/env/public instead.
|
||||
// Client-only. All /api calls go same-origin (SvelteKit in dev and prod);
|
||||
// auth rides on the httpOnly `pb_token` cookie, so no token/header needed.
|
||||
const BASE_URL = '';
|
||||
|
||||
async function memberFetch<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
_famId?: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
@@ -24,19 +20,13 @@ async function memberFetch<T = unknown>(
|
||||
}
|
||||
|
||||
export const memberApi = {
|
||||
async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) {
|
||||
return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date });
|
||||
async toggleCompletion(famId: string, assignedChoreId: string, date: string) {
|
||||
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date });
|
||||
},
|
||||
async myChores(token: string, famId: string) {
|
||||
return memberFetch('POST', '/api/members/my-chores', token, famId);
|
||||
async claimReward(famId: string, rewardId: string) {
|
||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
||||
},
|
||||
async claimReward(token: string, famId: string, rewardId: string) {
|
||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
|
||||
},
|
||||
async requestAllRewards(token: string, famId: string) {
|
||||
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
|
||||
},
|
||||
async payday(token: string, famId: string) {
|
||||
return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId);
|
||||
async payday(famId: string) {
|
||||
return memberFetch('POST', `/api/fam/${famId}/payday`);
|
||||
},
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { PROXY_URL } from '$app/env/public';
|
||||
|
||||
function sessionHeaders(event: RequestEvent): Record<string, string> {
|
||||
const u = event.locals.user;
|
||||
if (!u) return {};
|
||||
return {
|
||||
'x-session-famid': u.famId,
|
||||
'x-session-userid': u.id,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
headers?: Record<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 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -59,7 +59,7 @@ export async function createChild(opts: {
|
||||
}
|
||||
|
||||
// Admin grants access to a child: creates the users auth record (or re-issues
|
||||
// OTP if they already exist) + upserts their user_configs. Returns the OTP and
|
||||
// OTP if they already exist) + upserts their otp. Returns the OTP and
|
||||
// shareable join link (using the whitespace-free handle) for QR display.
|
||||
export async function issueAccess(opts: {
|
||||
famId: string;
|
||||
@@ -67,23 +67,23 @@ export async function issueAccess(opts: {
|
||||
name: string;
|
||||
colour?: string;
|
||||
}) {
|
||||
const { famId, famSlug, name, colour } = opts;
|
||||
const { famId, famSlug, name } = opts;
|
||||
const username = handle(name);
|
||||
const otp = generateOtp();
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
const user = await createChild({ famId, famSlug, name, colour });
|
||||
const user = await createChild({ famId, famSlug, name, colour: opts.colour });
|
||||
|
||||
const pb = await createSuperClient();
|
||||
let config = await pb
|
||||
.collection('user_configs')
|
||||
.collection('otp')
|
||||
.getFirstListItem(`famId='${famId}' && userId='${user.id}'`)
|
||||
.catch(() => null);
|
||||
|
||||
if (config) {
|
||||
await pb.collection('user_configs').update(config.id, { otp, colour, updatedAt });
|
||||
await pb.collection('otp').update(config.id, { otp, updatedAt });
|
||||
} else {
|
||||
await pb.collection('user_configs').create({ famId, userId: user.id, otp, colour, updatedAt });
|
||||
await pb.collection('otp').create({ famId, userId: user.id, otp, updatedAt });
|
||||
}
|
||||
|
||||
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` };
|
||||
@@ -109,7 +109,7 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp:
|
||||
if (!user || user.role !== 'child') throw new Error('Invalid join link');
|
||||
|
||||
let config = await pb
|
||||
.collection('user_configs')
|
||||
.collection('otp')
|
||||
.getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`)
|
||||
.catch(() => null);
|
||||
if (!config || config.otp !== otp) throw new Error('Invalid code');
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function pbUser(event: RequestEvent) {
|
||||
|
||||
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||
// operations that must bypass collection rules: creating child users, minting
|
||||
// OTP-login tokens, and verifying OTPs against the superuser-only user_configs.
|
||||
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
||||
let superClient: PocketBase | null = null;
|
||||
export async function createSuperClient() {
|
||||
if (superClient) return superClient;
|
||||
@@ -36,9 +36,8 @@ export async function createSuperClient() {
|
||||
return pb;
|
||||
}
|
||||
|
||||
// Superuser CRUD facade, built on the memoized SDK superuser client. Replaces
|
||||
// the old raw-fetch `pb-admin.ts`/`ensureToken` path so all server PB access
|
||||
// (authenticated user + superuser) lives in this one module.
|
||||
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
||||
// server PB access (authenticated user + superuser) lives in this one module.
|
||||
export const pbAdmin = {
|
||||
async getList(collection: string, filter = '') {
|
||||
const pb = await createSuperClient();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ class ChatStore {
|
||||
// ── Writes via SvelteKit server (forwards session/device auth) ──
|
||||
|
||||
private async serverChat(payload: Record<string, unknown>) {
|
||||
const res = await fetch('/chat', {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
|
||||
@@ -1,42 +1,40 @@
|
||||
import { PROXY_URL } from '$app/env/public';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
|
||||
import { createServices, type ChatActor } from '$lib/server/services';
|
||||
|
||||
const HONO_URL = PROXY_URL;
|
||||
|
||||
async function paydayCheck(famId: string, headers: Record<string, string>) {
|
||||
async function paydayCheck(famId: string, pbToken: string) {
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/fam/${famId}/payday`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
}
|
||||
});
|
||||
// Best-effort: never block render on the payday heartbeat.
|
||||
await res.json().catch(() => null);
|
||||
const s = createServices(createPbClient(pbToken));
|
||||
await s.fam.payday(famId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function resolveChatIdentity(
|
||||
api: 'admin' | 'member',
|
||||
opts: {
|
||||
session?: { famId: string; id: string };
|
||||
pbToken?: string;
|
||||
}
|
||||
) {
|
||||
function actorFrom(session: {
|
||||
famId: string;
|
||||
id: string;
|
||||
role: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
}): ChatActor {
|
||||
return {
|
||||
id: session.id,
|
||||
type: session.role === 'parent' ? 'admin' : 'member',
|
||||
name: session.name || '',
|
||||
color: session.color || '#6366f1'
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveChatIdentity(session: {
|
||||
famId: string;
|
||||
id: string;
|
||||
role: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
}, pbToken: string) {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (api === 'admin' && opts.session) {
|
||||
headers['x-session-famid'] = opts.session.famId;
|
||||
headers['x-session-userid'] = opts.session.id;
|
||||
} else if (api === 'member' && opts.pbToken) {
|
||||
headers['Authorization'] = `Bearer ${opts.pbToken}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const res = await fetch(`${HONO_URL}/api/chat/me`, { headers });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
const s = createServices(createPbClient(pbToken));
|
||||
const actor = actorFrom(session);
|
||||
return await s.chat.me(session.famId, actor);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -49,19 +47,12 @@ export async function load(event) {
|
||||
const pbToken = event.cookies.get('pb_token') || '';
|
||||
|
||||
let famId = '';
|
||||
let chat: { famId: string; actor: any } | null = null;
|
||||
let chat: { famId: string; actor: ChatActor } | null = null;
|
||||
|
||||
if (session && isParent) {
|
||||
if (session && pbToken) {
|
||||
famId = session.famId;
|
||||
await paydayCheck(famId, {
|
||||
'x-session-famid': session.famId,
|
||||
'x-session-userid': session.id
|
||||
});
|
||||
chat = await resolveChatIdentity('admin', { session });
|
||||
} else if (role === 'child' && pbToken && session) {
|
||||
famId = session.famId;
|
||||
await paydayCheck(famId, { Authorization: `Bearer ${pbToken}` });
|
||||
chat = await resolveChatIdentity('member', { pbToken });
|
||||
await paydayCheck(famId, pbToken);
|
||||
chat = await resolveChatIdentity(session, pbToken);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { pbUser } from '$lib/server/pocketbase';
|
||||
import { createServices } from '$lib/server/services';
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.user;
|
||||
if (!session) return {};
|
||||
|
||||
const famId = session.famId;
|
||||
const s = createServices(pbUser(event), session);
|
||||
|
||||
try {
|
||||
const [members, templates, assigned, summary] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.weeklySummary(event, famId),
|
||||
s.crud.list('members', famId),
|
||||
s.crud.list('chore-templates', famId),
|
||||
s.crud.list('assigned-chores', famId),
|
||||
s.fam.weeklySummary(famId),
|
||||
]);
|
||||
return { members, templates, assigned, summary };
|
||||
} catch {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { PROXY_URL } from '$app/env/public';
|
||||
|
||||
const HONO_URL = PROXY_URL;
|
||||
import { pbUser, createPbClient } from '$lib/server/pocketbase';
|
||||
import { createServices } from '$lib/server/services';
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.user;
|
||||
@@ -17,6 +15,7 @@ export async function load(event) {
|
||||
if (session.name && session.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
}
|
||||
const s = createServices(pbUser(event), session);
|
||||
const [
|
||||
members,
|
||||
templates,
|
||||
@@ -28,15 +27,15 @@ export async function load(event) {
|
||||
completions,
|
||||
settings
|
||||
] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.weeklySummary(event, famId),
|
||||
hono.admin.fam(event, famId),
|
||||
hono.admin.rewards(event, famId),
|
||||
hono.admin.bonusConfigs(event, famId),
|
||||
hono.admin.completions(event, famId),
|
||||
hono.admin.settings(event, famId).catch(() => ({}))
|
||||
s.crud.list('members', famId),
|
||||
s.crud.list('chore-templates', famId),
|
||||
s.crud.list('assigned-chores', famId),
|
||||
s.fam.weeklySummary(famId),
|
||||
s.fam.get(famId),
|
||||
s.crud.list('rewards', famId),
|
||||
s.crud.list('bonus-configs', famId),
|
||||
s.crud.list('completions', famId),
|
||||
s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' }))
|
||||
]);
|
||||
return {
|
||||
role: 'parent',
|
||||
@@ -88,12 +87,8 @@ export async function load(event) {
|
||||
if (!pbToken || !famId) return empty;
|
||||
|
||||
try {
|
||||
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${pbToken}` }
|
||||
});
|
||||
if (!choresRes.ok) return empty;
|
||||
const chores = await choresRes.json();
|
||||
const s = createServices(createPbClient(pbToken), session ?? undefined);
|
||||
const chores = await s.chores.myChores(famId);
|
||||
return {
|
||||
role: 'child',
|
||||
token: pbToken,
|
||||
@@ -107,7 +102,7 @@ export async function load(event) {
|
||||
completions: chores.completions || [],
|
||||
rewards: chores.rewards || [],
|
||||
bonusConfigs: chores.bonusConfigs || [],
|
||||
tallies: chores.tallies || {},
|
||||
tallies: {} as Record<string, unknown>,
|
||||
payday: chores.payday,
|
||||
paydayTime: chores.paydayTime || '18:00',
|
||||
timezone: chores.timezone || 'auto',
|
||||
@@ -125,7 +120,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const on = fd.get('on') === 'true';
|
||||
try {
|
||||
const result = await hono.admin.updateSettings(event, famId, { simulateEow: on });
|
||||
const s = createServices(pbUser(event), event.locals.user);
|
||||
const result = await s.settings.update(famId, { simulateEow: on });
|
||||
return { simulateEow: result.simulateEow };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
|
||||
@@ -136,8 +132,9 @@ export const actions = {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
try {
|
||||
const preview = await hono.admin.eowPreview(event, famId);
|
||||
await hono.admin.updateSettings(event, famId, { simulateEow: true });
|
||||
const s = createServices(pbUser(event), event.locals.user);
|
||||
const preview = await s.fam.eowPreview(famId);
|
||||
await s.settings.update(famId, { simulateEow: true });
|
||||
return { preview, simulateEow: true };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
||||
@@ -150,7 +147,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
||||
const s = createServices(pbUser(event), event.locals.user);
|
||||
const record = await s.rewards.approve(famId, rewardId);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||
@@ -163,7 +161,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const memberId = fd.get('memberId') as string;
|
||||
try {
|
||||
const result = await hono.admin.issueAllRewards(event, famId, memberId);
|
||||
const s = createServices(pbUser(event), event.locals.user);
|
||||
const result = await s.rewards.issueAll(famId, memberId);
|
||||
return { count: result.count };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
|
||||
@@ -176,7 +175,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const completionId = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.revokeCompletion(event, famId, completionId);
|
||||
const s = createServices(pbUser(event), event.locals.user);
|
||||
await s.completions.revoke(famId, completionId);
|
||||
return { revoked: true, id: completionId };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
|
||||
@@ -191,12 +191,8 @@ export const actions = {
|
||||
const memberId = fd.get('memberId') as string;
|
||||
if (!configId) return { error: 'Config ID required' };
|
||||
try {
|
||||
const result = await hono.admin.triggerBonusConfig(
|
||||
event,
|
||||
famId,
|
||||
configId,
|
||||
memberId || undefined
|
||||
);
|
||||
const s = createServices(pbUser(event), event.locals.user);
|
||||
const result = await s.bonuses.trigger(famId, configId, memberId || undefined);
|
||||
return { records: result.records || [] };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
if (secondsLeft > 0 || eowFired) return;
|
||||
if (!pbToken || !famId) return;
|
||||
eowFired = true;
|
||||
memberApi.payday(pbToken, famId).catch(() => {});
|
||||
memberApi.payday(famId).catch(() => {});
|
||||
});
|
||||
|
||||
function paydayWeekStart(): string {
|
||||
@@ -546,7 +546,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await memberApi.toggleCompletion(pbToken, famId, chore.id, todayChild);
|
||||
await memberApi.toggleCompletion(famId, chore.id, todayChild);
|
||||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||||
if (optimistic) {
|
||||
famStore.applyRecord('completions', optimistic, 'delete');
|
||||
@@ -960,12 +960,9 @@
|
||||
const old = memberName;
|
||||
memberName = nameInput;
|
||||
try {
|
||||
const res = await fetch('/api/members/me', {
|
||||
const res = await fetch('/api/members', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${pbToken}`
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: nameInput })
|
||||
});
|
||||
if (!res.ok) memberName = old;
|
||||
@@ -1022,12 +1019,9 @@
|
||||
memberColor = color;
|
||||
showColorPicker = false;
|
||||
try {
|
||||
const res = await fetch('/api/members/me', {
|
||||
const res = await fetch('/api/members', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${pbToken}`
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ color })
|
||||
});
|
||||
if (!res.ok) memberColor = old;
|
||||
@@ -1253,7 +1247,7 @@
|
||||
class="wr-cta"
|
||||
onclick={async () => {
|
||||
try {
|
||||
await memberApi.claimReward(pbToken, famId, r.id);
|
||||
await memberApi.claimReward(famId, r.id);
|
||||
} catch (e) {
|
||||
claimError = e instanceof Error ? e.message : 'Claim failed';
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const s = servicesFor(event);
|
||||
const [configs, templates, members, progress, rewards] = await Promise.all([
|
||||
hono.admin.bonusConfigs(event, famId),
|
||||
hono.admin.list(event, 'bonus-templates', famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.bonusConfigProgress(event, famId),
|
||||
hono.admin.rewards(event, famId)
|
||||
s.crud.list('bonus-configs', famId),
|
||||
s.crud.list('bonus-templates', famId),
|
||||
s.crud.list('members', famId),
|
||||
s.bonuses.progress(famId),
|
||||
s.crud.list('rewards', famId)
|
||||
]);
|
||||
return { configs, templates, members, progress, rewards };
|
||||
}
|
||||
@@ -34,7 +35,8 @@ export const actions = {
|
||||
if (period !== null) data.period = period;
|
||||
if (data.occurrence === 'once') data.period = '';
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'bonus-templates', famId, data);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.create('bonus-templates', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
||||
@@ -67,7 +69,8 @@ export const actions = {
|
||||
const description = fd.get('description');
|
||||
if (description) data.description = description;
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'bonus-templates', famId, id, data);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.update('bonus-templates', famId, id, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
|
||||
@@ -80,7 +83,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.remove(event, 'bonus-templates', famId, id);
|
||||
const s = servicesFor(event);
|
||||
await s.crud.remove('bonus-templates', famId, id);
|
||||
return { deleted: true };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
||||
@@ -112,7 +116,8 @@ export const actions = {
|
||||
const memberId = fd.get('memberId');
|
||||
if (memberId) data.memberId = memberId;
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.create('bonus-configs', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
|
||||
@@ -147,7 +152,8 @@ export const actions = {
|
||||
const memberId = fd.get('memberId');
|
||||
if (memberId !== null) data.memberId = memberId || null;
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'bonus-configs', famId, id, data);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.update('bonus-configs', famId, id, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
|
||||
@@ -160,7 +166,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.remove(event, 'bonus-configs', famId, id);
|
||||
const s = servicesFor(event);
|
||||
await s.crud.remove('bonus-configs', famId, id);
|
||||
return { deleted: true };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' });
|
||||
@@ -188,7 +195,8 @@ export const actions = {
|
||||
};
|
||||
if (data.occurrence === 'once') data.period = '';
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.create('bonus-configs', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, {
|
||||
@@ -205,7 +213,8 @@ export const actions = {
|
||||
const target = fd.get('target') as string;
|
||||
const memberId = fd.get('memberId') as string;
|
||||
try {
|
||||
const record = await hono.admin.assignBonusConfig(event, famId, id, {
|
||||
const s = servicesFor(event);
|
||||
const record = await s.bonuses.assign(famId, id, {
|
||||
target,
|
||||
memberId: memberId || null
|
||||
});
|
||||
@@ -221,7 +230,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.completeBonusConfig(event, famId, id);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.bonuses.complete(famId, id);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' });
|
||||
@@ -234,7 +244,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.destroyBonusConfig(event, famId, id);
|
||||
const s = servicesFor(event);
|
||||
await s.bonuses.destroy(famId, id);
|
||||
return { destroyed: true };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
|
||||
@@ -249,7 +260,8 @@ export const actions = {
|
||||
const currentStatus = fd.get('currentStatus') as string;
|
||||
const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled';
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'bonus-configs', famId, id, {
|
||||
const s = servicesFor(event);
|
||||
const record = await s.crud.update('bonus-configs', famId, id, {
|
||||
status: newStatus
|
||||
});
|
||||
return { record };
|
||||
@@ -262,7 +274,8 @@ export const actions = {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
try {
|
||||
await hono.admin.evaluateBonusConfig(event, famId);
|
||||
const s = servicesFor(event);
|
||||
await s.bonuses.evaluate(famId);
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
|
||||
export async function GET(event) {
|
||||
const famId = event.params.fam;
|
||||
try {
|
||||
const progress = await hono.admin.bonusConfigProgress(event, famId);
|
||||
const s = servicesFor(event);
|
||||
const progress = await s.bonuses.progress(famId);
|
||||
return json(progress);
|
||||
} catch {
|
||||
return json([]);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const s = servicesFor(event);
|
||||
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||
hono.admin.rewards(event, famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.completions(event, famId)
|
||||
s.crud.list('rewards', famId),
|
||||
s.crud.list('members', famId),
|
||||
s.crud.list('assigned-chores', famId),
|
||||
s.crud.list('chore-templates', famId),
|
||||
s.crud.list('completions', famId)
|
||||
]);
|
||||
return { rewards, members, assigned, templates, completions };
|
||||
}
|
||||
@@ -21,7 +22,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
||||
const s = servicesFor(event);
|
||||
const record = await s.rewards.approve(famId, rewardId);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { PROXY_URL } from '$app/env/public';
|
||||
|
||||
const HONO_URL = PROXY_URL;
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.user;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
|
||||
// Parent (session auth) — profile lives on the users record
|
||||
if (session) {
|
||||
if (!session) {
|
||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
|
||||
const famId = session.famId;
|
||||
if (session.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}/preferences`);
|
||||
}
|
||||
|
||||
const s = servicesFor(event);
|
||||
|
||||
if (session.role === 'parent') {
|
||||
try {
|
||||
const me = await hono.admin.getProfile(event, famId);
|
||||
const me = await s.fam.getProfile(famId);
|
||||
return {
|
||||
verified: true, token: '', memberId: me.id, famId,
|
||||
memberName: me.name, memberColor: me.color, email: me.email || '',
|
||||
@@ -27,71 +30,47 @@ export async function load(event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Child (device token) — profile lives in members
|
||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||
if (!deviceToken) {
|
||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceToken, famSlug }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.name !== username) {
|
||||
return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
// Child — profile lives on the users record, read from the session.
|
||||
return {
|
||||
verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId,
|
||||
memberName: data.name, memberColor: data.color, email: data.email || '', session: false,
|
||||
verified: true, token: event.cookies.get('pb_token') || '', memberId: session.id, famId,
|
||||
memberName: session.name || '', memberColor: session.color || '', email: '',
|
||||
session: true,
|
||||
};
|
||||
} catch {
|
||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async (event) => {
|
||||
const session = event.locals.user;
|
||||
if (!session) return { error: 'Not authenticated' };
|
||||
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
const color = fd.get('color') as string;
|
||||
const email = fd.get('email') as string;
|
||||
|
||||
if (session) {
|
||||
const s = servicesFor(event);
|
||||
const famId = session.famId;
|
||||
|
||||
if (session.role === 'parent') {
|
||||
try {
|
||||
const data: Record<string, string> = {};
|
||||
if (name) data.name = name;
|
||||
if (color) data.color = color;
|
||||
data.email = email || '';
|
||||
const me = await hono.admin.updateProfile(event, famId, data);
|
||||
const me = await s.fam.updateProfile(famId, data);
|
||||
return { success: true, name: me.name, color: me.color, email: me.email };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||
}
|
||||
}
|
||||
|
||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||
const famSlug = event.params.fam;
|
||||
if (!deviceToken) return { error: 'Not authenticated' };
|
||||
|
||||
// Child — update own users record via their PB token.
|
||||
try {
|
||||
const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceToken, famSlug }),
|
||||
});
|
||||
const verify = await verifyRes.json();
|
||||
if (!verifyRes.ok) return { error: 'Verification failed' };
|
||||
const res = await fetch(`${HONO_URL}/api/members/me`, {
|
||||
method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { error: data.error || 'Update failed' };
|
||||
return { success: true, name: data.name, color: data.color };
|
||||
const data: Record<string, string> = {};
|
||||
if (name) data.name = name;
|
||||
if (color) data.color = color;
|
||||
const me = await s.fam.updateProfile(famId, data);
|
||||
return { success: true, name: me.name, color: me.color, email: '' };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { pbUser } from '$lib/server/pocketbase';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
import { issueAccess, createChild } from '$lib/server/member-otp';
|
||||
import { slugify } from '@shared/slugify';
|
||||
|
||||
@@ -124,11 +124,12 @@ export const actions = {
|
||||
return { deletedChoreIds: deletedIds };
|
||||
},
|
||||
|
||||
// Compute endpoints — still proxied to Hono.
|
||||
// Compute endpoints — in-process.
|
||||
completeWeek: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
try {
|
||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
|
||||
const s = servicesFor(event);
|
||||
const result = await s.fam.completeWeek(famId);
|
||||
return { success: true, result };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to complete week' };
|
||||
@@ -140,7 +141,8 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const days = parseInt((fd.get('days') as string) || '7', 10);
|
||||
try {
|
||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
|
||||
const s = servicesFor(event);
|
||||
const result = await s.debug.generateData(famId, days);
|
||||
return { success: true, result };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
||||
|
||||
@@ -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.user;
|
||||
const pbToken = event.cookies.get('pb_token') || '';
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
let famId = body.famId || '';
|
||||
|
||||
if (session?.role === 'parent' && session?.famId && session?.id) {
|
||||
// Admin (parent) — trust the verified session server-side.
|
||||
headers['x-session-famid'] = session.famId;
|
||||
headers['x-session-userid'] = session.id;
|
||||
famId = session.famId;
|
||||
} else if (session?.role === 'child' && pbToken && session?.famId) {
|
||||
// Member (child) — forward the pb_token; the proxy re-validates.
|
||||
headers['Authorization'] = `Bearer ${pbToken}`;
|
||||
famId = session.famId;
|
||||
} else {
|
||||
return json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
if (!famId) return json({ error: 'famId required' }, 400);
|
||||
|
||||
const path = body.action === 'typing' ? `/api/chat/${famId}/typing` : `/api/chat/${famId}/messages`;
|
||||
const payload =
|
||||
body.action === 'typing'
|
||||
? { typing: Boolean(body.typing) }
|
||||
: { content: body.content || '', clientId: body.clientId || '' };
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return json({ error: data.error || 'chat request failed' }, res.status);
|
||||
return json(data);
|
||||
} catch {
|
||||
return json({ error: 'chat request failed' }, 502);
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,8 @@ export default defineConfig(() => {
|
||||
allow: ['.', './node_modules', '../node_modules']
|
||||
},
|
||||
// Dev only: allow access via any host/LAN IP without hardcoding it.
|
||||
allowedHosts: true,
|
||||
port: 2080,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// The vite dev server and Hono proxy run on the same host.
|
||||
target: `http://127.0.0.1:3456`,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
allowedHosts: true as true,
|
||||
port: 2080
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
+2
-2
@@ -3,9 +3,9 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
"dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev",
|
||||
"dev": "pnpm --filter frontend dev",
|
||||
"start": "pnpm dev",
|
||||
"build": "pnpm -r build"
|
||||
"build": "pnpm --filter frontend build"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+2
-45
@@ -64,28 +64,6 @@ importers:
|
||||
specifier: 8.0.16
|
||||
version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
|
||||
proxy:
|
||||
dependencies:
|
||||
'@hono/node-server':
|
||||
specifier: ^1.13.0
|
||||
version: 1.19.14(hono@4.12.27)
|
||||
hono:
|
||||
specifier: ^4.7.0
|
||||
version: 4.12.27
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^26.0.0
|
||||
version: 26.0.0
|
||||
esbuild:
|
||||
specifier: ^0.28.1
|
||||
version: 0.28.1
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.22.4
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
@@ -265,12 +243,6 @@ packages:
|
||||
'@hiseb/confetti@2.2.0':
|
||||
resolution: {integrity: sha512-iCcTe2AS2Mnj7f2BGPnOetjnX+Qs1jgnKU0GSYyQHFB42psio0EpgxmPXOXf2wcCGBH/W+1G2Ecl4hcbsin1Kg==}
|
||||
|
||||
'@hono/node-server@1.19.14':
|
||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -761,10 +733,6 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
hono@4.12.27:
|
||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1062,11 +1030,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -1261,10 +1224,6 @@ snapshots:
|
||||
|
||||
'@hiseb/confetti@2.2.0': {}
|
||||
|
||||
'@hono/node-server@1.19.14(hono@4.12.27)':
|
||||
dependencies:
|
||||
hono: 4.12.27
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1609,6 +1568,7 @@ snapshots:
|
||||
'@esbuild/win32-arm64': 0.28.1
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
optional: true
|
||||
|
||||
esm-env@1.2.2: {}
|
||||
|
||||
@@ -1632,8 +1592,6 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hono@4.12.27: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-reference@3.0.3:
|
||||
@@ -1879,8 +1837,7 @@ snapshots:
|
||||
esbuild: 0.28.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
optional: true
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
injectWorkspacePackages: true
|
||||
packages:
|
||||
- "frontend"
|
||||
- "proxy"
|
||||
onlyBuiltDependencies:
|
||||
- "@tailwindcss/oxide"
|
||||
- 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,21 +0,0 @@
|
||||
// Runtime env for the proxy. Same dev/prod split as the frontend: dev connects
|
||||
// to the dev machine's PocketBase at SERVER_IP, prod connects to the
|
||||
// container-internal loopback PB. Ports come from config.ts (single source).
|
||||
//
|
||||
// Env loading: dev uses `tsx --env-file=../.env` (see package.json) like vite
|
||||
// does for the frontend; prod (docker) injects env via compose and has no .env,
|
||||
// so SERVER_IP is unset → loopback below.
|
||||
const SERVER_IP = process.env.SERVER_IP;
|
||||
|
||||
// PB_ENDPOINT can be overridden explicitly (used for migration step-through
|
||||
// against a throwaway PB on another port). Defaults to the dev/prod split.
|
||||
export const PB_ENDPOINT =
|
||||
process.env.PB_ENDPOINT ||
|
||||
(SERVER_IP ? `http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`);
|
||||
export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev";
|
||||
export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123";
|
||||
// Shared secret used to DERIVE a child's users password as
|
||||
// `MEMBER_SECRET + famSlug + username` (same formula as the frontend
|
||||
// member-otp.ts). Never typed by anyone; OTP is the access gate.
|
||||
export const MEMBER_SECRET =
|
||||
process.env.MEMBER_SECRET || "famchamp-member-secret";
|
||||
-2338
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/**/*"]
|
||||
}
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
// Single source of truth for the three service ports (dev/build-time only,
|
||||
// used by the Hono proxy). Runtime URLs are set via env (see proxy/src/env.ts).
|
||||
// Single source of truth for service ports (dev/build-time only). Runtime URLs
|
||||
// come from env (see frontend/src/env.ts).
|
||||
export const FRONTEND_PORT = "2080";
|
||||
export const PROXY_PORT = "3456";
|
||||
export const PB_PORT = "8090";
|
||||
+98
-19
@@ -1,9 +1,14 @@
|
||||
// Single source of truth for the PocketBase schema + field builders.
|
||||
// Consumed by BOTH proxy/src/migrate.ts (idempotent bootstrap) and
|
||||
// proxy/scripts/seed.ts (fresh-store seed) so the schema isn't duplicated.
|
||||
// Consumed by frontend/src/lib/server/migrate.ts (idempotent bootstrap) so the
|
||||
// schema isn't duplicated.
|
||||
//
|
||||
// Relations reference collections by name; the `ids` map maps collection
|
||||
// name -> runtime id (filled as each collection is created).
|
||||
//
|
||||
// NOTE: the native `users` auth collection and the superuser-only `otp`
|
||||
// collection are NOT in SCHEMA_PLAN — they're applied separately in
|
||||
// migrate.ts (users is PB's built-in auth model; `otp` needs null rules,
|
||||
// which the `col()` builder can't express). Everything else lives here.
|
||||
|
||||
export interface FieldDef {
|
||||
name: string;
|
||||
@@ -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 ──
|
||||
export function col(
|
||||
name: string,
|
||||
fields: FieldDef[],
|
||||
rules: { listRule?: string | null; viewRule?: string | null } = {},
|
||||
rules: {
|
||||
listRule?: string | null;
|
||||
viewRule?: string | null;
|
||||
createRule?: string | null;
|
||||
updateRule?: string | null;
|
||||
deleteRule?: string | null;
|
||||
} = {},
|
||||
): (ids: Record<string, string>) => CollectionDef {
|
||||
return (ids) => ({
|
||||
name,
|
||||
type: "base",
|
||||
listRule: rules.listRule ?? "",
|
||||
viewRule: rules.viewRule ?? "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
createRule: rules.createRule ?? null,
|
||||
updateRule: rules.updateRule ?? null,
|
||||
deleteRule: rules.deleteRule ?? null,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
@@ -105,9 +133,12 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
uniqueText("slug"),
|
||||
text("stripeCustomerId"),
|
||||
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),
|
||||
},
|
||||
{
|
||||
@@ -124,12 +155,20 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
text("rewardValue", true),
|
||||
number("criteriaValue"),
|
||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
build: (ids) =>
|
||||
col("settings", [rel("famId", ids.fams, true), text("webhookUrl")])(ids),
|
||||
col("settings", [rel("famId", ids.fams, true), text("webhookUrl"), bool("simulateEow")], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "chore_templates",
|
||||
@@ -141,7 +180,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
select("defaultFrequency", ["daily", "weekly"], true),
|
||||
select("defaultType", ["points", "money"], true),
|
||||
number("defaultValue", true),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "bonus_configs",
|
||||
@@ -159,7 +202,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
rel("memberId", ids.users),
|
||||
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||
select("status", ["active", "completed"], true),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "weekly_history",
|
||||
@@ -172,7 +219,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
number("moneyEarned"),
|
||||
number("choresCompleted"),
|
||||
number("bonusEarned"),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "seasons",
|
||||
@@ -184,7 +235,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
bool("active"),
|
||||
date("autoDisable"),
|
||||
date("autoStart"),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "messages",
|
||||
@@ -199,7 +254,12 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
// Explicit createdAt: PB 0.39 does NOT auto-add createdAt to
|
||||
// API-created collections (0.25 did). Chat filters/sorts on it.
|
||||
date("createdAt"),
|
||||
])(ids),
|
||||
text("clientId"),
|
||||
], {
|
||||
createRule: RULE_FAM_WRITE,
|
||||
updateRule: RULE_FAM_SCOPED,
|
||||
deleteRule: RULE_FAM_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "chat_typing",
|
||||
@@ -211,7 +271,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
text("authorName", true),
|
||||
text("authorColor"),
|
||||
bool("typing"),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_FAM_WRITE,
|
||||
updateRule: RULE_FAM_SCOPED,
|
||||
deleteRule: RULE_FAM_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "rewards",
|
||||
@@ -229,7 +293,11 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
date("claimedAt"),
|
||||
date("requestedAt"),
|
||||
text("date"),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_FAM_WRITE,
|
||||
updateRule: RULE_FAM_SCOPED,
|
||||
deleteRule: RULE_FAM_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "assigned_chores",
|
||||
@@ -243,7 +311,14 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
number("value", true),
|
||||
text("customName"),
|
||||
jsonField("seasonIds"),
|
||||
])(ids),
|
||||
bool("isTodo"),
|
||||
text("startDate"),
|
||||
text("completeBy"),
|
||||
], {
|
||||
createRule: RULE_PARENT_WRITE,
|
||||
updateRule: RULE_PARENT_SCOPED,
|
||||
deleteRule: RULE_PARENT_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
{
|
||||
name: "completions",
|
||||
@@ -254,6 +329,10 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
||||
rel("assignedChoreId", ids.assigned_chores, true),
|
||||
date("date"),
|
||||
date("completedAt"),
|
||||
])(ids),
|
||||
], {
|
||||
createRule: RULE_FAM_WRITE,
|
||||
updateRule: RULE_FAM_SCOPED,
|
||||
deleteRule: RULE_FAM_SCOPED,
|
||||
})(ids),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user