diff --git a/.gitignore b/.gitignore index 7f2dbf3..d67fcf0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules /.svelte-kit /build + # OS .DS_Store Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index 6462ca9..cddbb45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,15 @@ ## Auth -| Role | Auth | Session | -| ---------------- | -------------------------- | --------------------------- | -| Super admin (me) | PB email+pass | 24hr JWT | -| Fam admin | PB email+pass | 24hr JWT, scoped to own fam | -| Member | Invite code + device token | localStorage, no expiry | +| Role | Auth | Session | Record in | +| ---------------- | -------------------------- | --------------------------- | -------------------- | +| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | +| Member (child) | Invite code + device token | `device_token` cookie only | `members` | + +- **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`. +- **Members** (children) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie. +- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard). Not an app role. +- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session cookie — child pages use `page.data.isParent` or `page.data.role` from `$app/state`. ## PB Collections (all scoped by `famId`) @@ -42,9 +46,15 @@ / Landing (SaaS marketing) /admin Admin panel - statistic dashboard, and any donations made /join/:code Member invite code +/join/:code/:member Member invite with pre-selected member /{fam} Fam dashboard /{fam}/admin Admin panel -/{fam}/:username Member kanban (?token= for auth) +/{fam}/:username Member kanban & admin dashboard (role determined by session) +/{fam}/:username/preferences User preferences (admin→fam_admins, member→members) +/{fam}/:username/settings Family admin settings (session required) +/{fam}/:username/chores Chore templates & assignment grid +/{fam}/:username/rewards Rewards overview +/{fam}/:username/bonuses Bonus configs & evaluation /api/* Hono proxy (webhooks, CRON) ``` @@ -58,11 +68,82 @@ - **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface - **UI reactivity:** Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE +## Env Vars (SvelteKit 3.0.0-next.4) + +Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`: + +```ts +import { defineEnvVars } from '@sveltejs/kit/hooks'; +export const variables = defineEnvVars({ + DEBUG_RECORD_ID: {}, + SERVER_IP: {public: true}, + PB_PORT: {public: true} +}); +``` + +Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must: +1. Add it to `.env` with `PUBLIC_` prefix +2. Add it to `frontend/src/env.ts` with `{public: true}` + +The actual values come from `.env` (symlinked from project root at `frontend/.env -> ../.env`). Server-only env vars (no `{public}` flag) are available via `$app/env/private` but only in server modules. + +## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore) + +**Do NOT do this:** +```svelte + +let items = $state(data.items); +$effect(() => { + if (data.items?.length) items = data.items; + if (famStore.initialized && famStore.items.length) items = famStore.items; +}); +``` + +**Do this instead:** +```svelte + +let items = $state(famStore.initialized ? famStore.items : (data.items || [])); +``` + +**Form action callbacks must call `famStore.applyRecord()`:** +```ts +// In use:enhance callback: +famStore.applyRecord('collection_name', record, 'create' | 'update' | 'delete'); +``` + +The `famStore` has reactive `$state` properties and an `applyRecord()` method designed for instant UI feedback from form actions. Calling `applyRecord()` mutates the store directly — the UI updates immediately without waiting for PB SSE. The SSE subscription is a backup for multi-user sync only. Do NOT add `$effect` watchers to bridge the gap between form actions and reactive state. + +## UI Component Conventions + +All admin and member pages use the following pattern: + +```svelte + + + + + + +``` + +- **Layout shell** lives in `[fam]/+layout.svelte` — Sidebar, TopNav, Footer. All fam-scoped routes inherit it. +- **Sidebar** role-aware: shows admin CTAs on `/admin/*`, member CTAs on `/[username]/*`. +- **TopNav** has `announcement` (center slot) and `actions` (right slot). +- **`Card` `cols` prop**: `1 | 2 | 3` — spans that many columns in the 3-column `CardGrid`. +- **`Button`** for all CTAs: ` + {#if openIndex === i} +
+ {@render item.content()} +
+ {/if} + + {/each} + + + diff --git a/frontend/src/lib/components/Button.svelte b/frontend/src/lib/components/Button.svelte new file mode 100644 index 0000000..42c9fa4 --- /dev/null +++ b/frontend/src/lib/components/Button.svelte @@ -0,0 +1,49 @@ + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} + + diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte new file mode 100644 index 0000000..c396fc1 --- /dev/null +++ b/frontend/src/lib/components/Card.svelte @@ -0,0 +1,31 @@ + + +
+ {#if title} +
+ {title} +
+ {/if} +
+ {@render children?.()} +
+
+ + diff --git a/frontend/src/lib/components/CardGrid.svelte b/frontend/src/lib/components/CardGrid.svelte new file mode 100644 index 0000000..429de91 --- /dev/null +++ b/frontend/src/lib/components/CardGrid.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
+ + diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte new file mode 100644 index 0000000..af932e7 --- /dev/null +++ b/frontend/src/lib/components/Footer.svelte @@ -0,0 +1,13 @@ +
+ FamChore +
+ + diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..669dd96 --- /dev/null +++ b/frontend/src/lib/components/Sidebar.svelte @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/lib/components/TopNav.svelte b/frontend/src/lib/components/TopNav.svelte new file mode 100644 index 0000000..1081032 --- /dev/null +++ b/frontend/src/lib/components/TopNav.svelte @@ -0,0 +1,30 @@ + + +
+
+ {#if announcement}{announcement}{/if} +
+
+ {@render children?.()} +
+
+ + diff --git a/frontend/src/lib/components/ViewHeader.svelte b/frontend/src/lib/components/ViewHeader.svelte new file mode 100644 index 0000000..b1dadd1 --- /dev/null +++ b/frontend/src/lib/components/ViewHeader.svelte @@ -0,0 +1,71 @@ + + +
+
+

{title}

+ {#if subtitle}

{subtitle}

{/if} +
+ +
+ {#if tabs} +
+ {#each tabs.items as tab} + + {/each} +
+ {/if} + + {#if weeknav} +
+ + {weeknav.current} + +
+ {/if} + + {#if sort} + + {/if} +
+
+ + diff --git a/frontend/src/lib/components/icons.ts b/frontend/src/lib/components/icons.ts new file mode 100644 index 0000000..70f4fd0 --- /dev/null +++ b/frontend/src/lib/components/icons.ts @@ -0,0 +1,11 @@ +export const dashboardIcon = '' +export const choresIcon = '' +export const rewardsIcon = '' +export const bonusesIcon = '' +export const homeIcon = '' +export const settingsIcon = '' +export const logoutIcon = '' +export const prefsIcon = '' +export const chevronLeft = '' +export const chevronRight = '' +export const bellIcon = '' diff --git a/frontend/src/lib/components/index.ts b/frontend/src/lib/components/index.ts new file mode 100644 index 0000000..7f53072 --- /dev/null +++ b/frontend/src/lib/components/index.ts @@ -0,0 +1,8 @@ +export { default as Sidebar } from './Sidebar.svelte'; +export { default as TopNav } from './TopNav.svelte'; +export { default as Footer } from './Footer.svelte'; +export { default as ViewHeader } from './ViewHeader.svelte'; +export { default as Card } from './Card.svelte'; +export { default as CardGrid } from './CardGrid.svelte'; +export { default as Button } from './Button.svelte'; +export { default as Accordion } from './Accordion.svelte'; diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts new file mode 100644 index 0000000..efe015a --- /dev/null +++ b/frontend/src/lib/crypto.ts @@ -0,0 +1,9 @@ +export async function sha256(input: string): Promise { + const hash = await globalThis.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(input), + ); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index d7046b4..0599ee5 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -1,5 +1,14 @@ import PocketBase from 'pocketbase'; -import { SERVER_IP, PB_PORT } from '$app/env/public'; +import { PUBLIC_PB_URL } from '$app/env/public'; -const url = `http://${SERVER_IP}:${PB_PORT}`; -export const pb: PocketBase = new PocketBase(url); +export const pb = new PocketBase(PUBLIC_PB_URL); +pb.autoCancellation(false); + +export function initPbFromCookie() { + const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/); + if (match) { + pb.authStore.save(match[1], null); + return true; + } + return false; +} diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts new file mode 100644 index 0000000..087b4e7 --- /dev/null +++ b/frontend/src/lib/server/auth.ts @@ -0,0 +1,87 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export function getSession(event: RequestEvent) { + return event.locals.session; +} + +export function requireAuth(event: RequestEvent) { + const session = getSession(event); + if (!session) { + throw redirect(303, '/login'); + } + return session; +} + +export async function signup(email: string, password: string, famName: string, parentName?: string) { + const res = await fetch(`${HONO_URL}/api/admin/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, famName, parentName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Signup failed'); + return data; +} + +export async function login(email: string, password: string) { + console.log(email); + const res = await fetch(`${HONO_URL}/api/admin/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Login failed'); + return data; +} + +export async function joinMember(inviteCode: string, name: string, deviceToken: string) { + const res = await fetch(`${HONO_URL}/api/members/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode, name, deviceToken }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Join failed'); + return data; +} + +export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) { + event.cookies.set('session', JSON.stringify(session), { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30, + secure: false, + }); +} + +export function setDeviceTokenCookie(event: RequestEvent, token: string) { + event.cookies.set('device_token', token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 365, + secure: false, + }); +} + +export function setPbTokenCookie(event: RequestEvent, token: string) { + event.cookies.set('pb_token', token, { + httpOnly: false, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24, + secure: false, + }); +} + +export function clearSession(event: RequestEvent) { + event.cookies.delete('session', { path: '/' }); + event.cookies.delete('pb_token', { path: '/' }); + event.cookies.delete('device_token', { path: '/' }); +} diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts new file mode 100644 index 0000000..0581410 --- /dev/null +++ b/frontend/src/lib/server/hono.ts @@ -0,0 +1,111 @@ +import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; +import type { RequestEvent } from '@sveltejs/kit'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +function sessionHeaders(event: RequestEvent): Record { + const s = event.locals.session; + if (!s) return {}; + return { + 'x-session-famid': s.famId, + 'x-session-userid': s.userId, + 'Content-Type': 'application/json', + }; +} + +async function request(method: string, path: string, body?: unknown, headers?: Record) { + const res = await fetch(`${HONO_URL}${path}`, { + method, + headers: headers || { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); + return data; +} + +export const hono = { + admin: { + async list(event: RequestEvent, resource: string, famId: string) { + return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event)); + }, + async create(event: RequestEvent, resource: string, famId: string, data: Record) { + return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event)); + }, + async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record) { + return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event)); + }, + async remove(event: RequestEvent, resource: string, famId: string, id: string) { + return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event)); + }, + async renameFam(event: RequestEvent, famId: string, name: string) { + return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event)); + }, + async updatePayday(event: RequestEvent, famId: string, payday: number) { + return request('PATCH', `/api/admin/${famId}/fam`, { payday }, sessionHeaders(event)); + }, + async fam(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event)); + }, + async verify(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event)); + }, + async regenInvite(event: RequestEvent, famId: string) { + return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event)); + }, + async weeklySummary(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event)); + }, + async completions(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/completions`, undefined, sessionHeaders(event)); + }, + async rewards(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event)); + }, + async claimReward(event: RequestEvent, famId: string, rewardId: string) { + return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event)); + }, + async bonusConfigs(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event)); + }, + async bonusConfigProgress(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event)); + }, + async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) { + return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event)); + }, + async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) { + return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event)); + }, + async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record) { + return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event)); + }, + async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) { + return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event)); + }, + async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) { + return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event)); + }, + async bonusConfigTallies(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event)); + }, + async revokeCompletion(event: RequestEvent, famId: string, completionId: string) { + return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event)); + }, + async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) { + return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event)); + }, + async memberChores(event: RequestEvent, famId: string, memberId: string) { + return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event)); + }, + async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record) { + return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event)); + }, + async getProfile(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event)); + }, + async updateProfile(event: RequestEvent, famId: string, data: Record) { + return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event)); + }, + }, +}; diff --git a/frontend/src/lib/server/pb-admin.ts b/frontend/src/lib/server/pb-admin.ts new file mode 100644 index 0000000..7073243 --- /dev/null +++ b/frontend/src/lib/server/pb-admin.ts @@ -0,0 +1,45 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts'; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +let token: string | null = null; +let tokenExpiry = 0; + +async function ensureToken(): Promise { + if (token && Date.now() < tokenExpiry) return token; + const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`); + token = data.token; + tokenExpiry = Date.now() + 23 * 60 * 60 * 1000; + return token!; +} + +export const pbAdmin = { + async getList(collection: string, filter = '') { + const t = await ensureToken(); + const params = new URLSearchParams(); + if (filter) params.set('filter', filter); + params.set('perPage', '200'); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`); + return data.items || []; + }, + + async getOne(collection: string, id: string) { + const t = await ensureToken(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`); + return data; + }, +}; diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts new file mode 100644 index 0000000..7375988 --- /dev/null +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -0,0 +1,155 @@ +import { pb } from '$lib/pocketbase'; +import type { + Member, ChoreTemplate, AssignedChore, Completion, + WeeklyHistory, Reward, BonusConfig, Fam, +} from '$lib/types'; + +type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards'; + +class FamStore { + fam = $state(null) + members = $state([]) + templates = $state([]) + assigned = $state([]) + completions = $state([]) + history = $state([]) + rewards = $state([]) + bonusConfigs = $state([]) + initialized = $state(false) + famId = $state('') + + private unsubs: (() => void)[] = [] + private destroyed = false + + memberMap(): Map { + return new Map(this.members.map((m) => [m.id, m])) + } + + templateMap(): Map { + return new Map(this.templates.map((t) => [t.id, t])) + } + + bonusConfigMap(): Map { + return new Map(this.bonusConfigs.map((b) => [b.id, b])) + } + + assignedForMember(memberId: string): AssignedChore[] { + return this.assigned.filter((a) => a.memberId === memberId) + } + + completionsForDate(date: string): Completion[] { + return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date) + } + + isCompleted(assignedChoreId: string, date: string): boolean { + return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date) + } + + private initPromise: Promise | null = null + + async init(famId: string) { + if (this.initialized && this.famId === famId) return + + // Wait for any in-flight init to finish first + if (this.initPromise) { + await this.initPromise + if (this.initialized && this.famId === famId) return + } + + this.cleanup() + this.destroyed = false + this.famId = famId + + this.initPromise = (async () => { + try { + const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] = + await Promise.all([ + pb.collection('fams').getOne(famId) as Promise, + pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise, + ]) + this.fam = famRes + this.members = membersRes + this.templates = templatesRes + this.assigned = assignedRes + this.completions = completionsRes + this.bonusConfigs = bonusConfigsRes + this.rewards = rewardsRes + this.initialized = true + } catch (e) { + console.error('FamStore.init failed:', e) + this.initPromise = null + throw e + } + + await this.subscribe() + this.initPromise = null + })() + + return this.initPromise! + } + + private async subscribe() { + const subs: { collection: CollectionName; filter?: string }[] = [ + { collection: 'members', filter: this.famId }, + { collection: 'chore_templates', filter: this.famId }, + { collection: 'assigned_chores', filter: this.famId }, + { collection: 'completions', filter: this.famId }, + { collection: 'bonus_configs', filter: this.famId }, + { collection: 'rewards', filter: this.famId }, + ] + + const promises = subs.map(({ collection, filter }) => { + const filterStr = filter ? `famId = '${filter}'` : '' + return pb.collection(collection).subscribe('*', (data: any) => { + if (this.destroyed) return + this.handleRealtime(collection, data.action, data.record) + }, { filter: filterStr || undefined }).then((unsub) => { + if (this.destroyed) { unsub(); return } + this.unsubs.push(unsub) + }).catch((err: Error) => { + console.error(`[famStore] subscribe failed for ${collection}:`, err) + }) + }) + + await Promise.allSettled(promises) + } + + // Called from form action callbacks for instant UI feedback, + // and from PB subscribe SSE for multi-user realtime. + applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { + const apply = (list: T[]): T[] => { + if (action === 'create') return [record, ...list] + if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)) + if (action === 'delete') return list.filter((x) => x.id !== record.id) + return list + } + + switch (collection) { + case 'members': this.members = apply(this.members); break + case 'chore_templates': this.templates = apply(this.templates); break + case 'assigned_chores': this.assigned = apply(this.assigned); break + case 'completions': this.completions = apply(this.completions); break + case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break + case 'rewards': this.rewards = apply(this.rewards); break + } + } + + private handleRealtime(collection: string, action: string, record: any) { + this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete') + } + + cleanup() { + // TODO - we need to properly unsubscribe from pocketbase + this.destroyed = true + for (const unsub of this.unsubs) unsub() + this.unsubs = [] + this.initialized = false + } +} + +export const famStore = new FamStore() diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..165a45e --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,142 @@ +export type Frequency = 'daily' | 'weekly' +export type RewardType = 'points' | 'money' +export type BonusTarget = 'individual' | 'competitive' | 'collaborative' +export type BonusType = 'threshold' | 'count' | 'manual' +export type BonusOccurrence = 'recurring' | 'once' +export type BonusRewardType = 'points' | 'cash' | 'prize' +export type BonusPeriod = 'weekly' | 'monthly' +export type BonusStatus = 'active' | 'archived' +export type BonusState = 'pending' | 'unclaimed' | 'claimed' + +export interface Fam { + id: string + name: string + slug: string + inviteCode: string + stripeCustomerId?: string + featureFlags: Record + created: string + updated: string +} + +export interface Member { + id: string + famId: string + name: string + color: string + deviceToken: string + deviceTokenHint: string + created: string + updated: string +} + +export interface ChoreTemplate { + id: string + famId: string + name: string + description?: string + defaultFrequency: Frequency + defaultType: RewardType + defaultValue: number + created: string + updated: string +} + +export interface AssignedChore { + id: string + famId: string + memberId: string + templateId: string + frequency: Frequency + type: RewardType + value: number + customName?: string + created: string + updated: string +} + +export interface Completion { + id: string + famId: string + memberId: string + assignedChoreId: string + date: string + completedAt: string +} + +export interface WeeklyHistory { + id: string + famId: string + memberId: string + weekStart: string + pointsEarned: number + moneyEarned: number + choresCompleted: number + bonusEarned: number +} + +export interface BonusConfig { + id: string + famId: string + name: string + description?: string + target: BonusTarget + memberId?: string + type: BonusType + occurrence: BonusOccurrence + rewardType: BonusRewardType + rewardValue: string + criteriaValue?: number + period?: BonusPeriod + status: BonusStatus + phase?: 'template' | 'ready' | 'active' | 'completed' + created: string + updated: string +} + +export interface Reward { + id: string + famId: string + memberId: string + bonusConfigId?: string + label: string + value: number + rewardType: BonusRewardType + claimed: boolean + claimedAt?: string + date: string + created: string + updated: string +} + +export interface BonusProgress { + memberId: string + memberName: string + memberColor: string + current: number + criteriaValue: number + reward: { id: string; claimed: boolean } | null + state: BonusState + achieved: boolean +} + +export interface BonusConfigWithProgress { + config: BonusConfig + progress: BonusProgress[] + periodStart: string + periodEnd: string +} + +export interface Settings { + id: string + famId: string + webhookUrl?: string +} + +export interface Session { + famId: string + userId: string + famSlug: string + memberName?: string + role?: string +} diff --git a/frontend/src/routes/[fam]/+error.svelte b/frontend/src/routes/[fam]/+error.svelte new file mode 100644 index 0000000..3e24f0c --- /dev/null +++ b/frontend/src/routes/[fam]/+error.svelte @@ -0,0 +1,18 @@ + + +
+

Something went wrong

+

{error?.message || 'An unexpected error occurred.'}

+

Try logging out and signing in again.

+
+ + diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts new file mode 100644 index 0000000..093e12b --- /dev/null +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -0,0 +1,4 @@ +export function load(event) { + const session = event.locals.session || null; + return { session, isParent: session !== null, role: session?.role || 'child' }; +} diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte new file mode 100644 index 0000000..260d738 --- /dev/null +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -0,0 +1,69 @@ + + +
+ + +
+ {@render children()} +
+ {#if claimToast} +
{claimToast}
+ {/if} +
+
+ + diff --git a/frontend/src/routes/[fam]/+page.server.ts b/frontend/src/routes/[fam]/+page.server.ts new file mode 100644 index 0000000..edcaadb --- /dev/null +++ b/frontend/src/routes/[fam]/+page.server.ts @@ -0,0 +1,20 @@ +import { hono } from '$lib/server/hono'; + +export async function load(event) { + const session = event.locals.session; + if (!session) return {}; + + const famId = session.famId; + + try { + const [members, templates, assigned, summary] = await Promise.all([ + hono.admin.list(event, 'members', famId), + hono.admin.list(event, 'chore-templates', famId), + hono.admin.list(event, 'assigned-chores', famId), + hono.admin.weeklySummary(event, famId), + ]); + return { members, templates, assigned, summary }; + } catch { + return {}; + } +} diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte new file mode 100644 index 0000000..f957488 --- /dev/null +++ b/frontend/src/routes/[fam]/+page.svelte @@ -0,0 +1,175 @@ + + +

🏠 {famSlug}

+ +{#if summary?.summaries?.length} +

This week

+ + + + {#each summary.summaries as s} + + + + + + {/each} + +
MemberPointsDone
{s.memberName}{s.pointsEarned}{s.choresCompleted}/{s.totalChores}
+ +
+

Chores per day

+ +
+ +
+ {#each summary.summaries as s} +
+ +
{s.memberName}
+
{s.pointsEarned} pts
+
{s.choresCompleted}/{s.totalChores} chores
+
+ {/each} +
+{/if} + +

Members ({members?.length || 0})

+
+ {#each members || [] as m} + + {m.name} + {m.deviceTokenHint || '—'} + + {/each} +
+ +{#if templates?.length} +

Templates ({templates.length})

+
    + {#each templates as t} +
  • {t.name} — {t.defaultFrequency} — {t.defaultValue} {t.defaultType}
  • + {/each} +
+{/if} + + diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts new file mode 100644 index 0000000..54ecd24 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -0,0 +1,122 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; +import { memberApi } from '$lib/client/api'; +import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export async function load(event) { + const session = event.locals.session; + + // Parent (session auth) → admin overview + if (session) { + const famId = session.famId; + const famSlug = event.params.fam; + const username = event.params.username; + if (session.memberName && session.memberName !== username) { + throw redirect(303, `/${famSlug}/${session.memberName}`); + } + const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = await Promise.all([ + hono.admin.list(event, 'members', famId), + hono.admin.list(event, 'chore-templates', famId), + hono.admin.list(event, 'assigned-chores', famId), + hono.admin.weeklySummary(event, famId), + hono.admin.fam(event, famId), + hono.admin.rewards(event, famId), + hono.admin.bonusConfigs(event, famId), + hono.admin.completions(event, famId), + ]); + return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions }; + } + + // Child (device token) → kanban + const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; + const famSlug = event.params.fam; + const username = event.params.username; + + if (!deviceToken) { + return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + } + + try { + const res = await fetch(`${HONO_URL}/api/members/verify-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceToken, famSlug }), + }); + const data = await res.json(); + if (!res.ok || data.name !== username) { + return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + } + + let chores: any = {}; + try { + chores = await memberApi.myChores(deviceToken, data.famId); + } catch {} + + return { + role: 'child', + token: deviceToken, + memberId: data.memberId, + famId: data.famId, + verified: true, + memberName: data.name, + memberColor: data.color, + templates: chores.templates || [], + assigned: chores.assigned || [], + completions: chores.completions || [], + rewards: chores.rewards || [], + bonusConfigs: chores.bonusConfigs || [], + tallies: chores.tallies || {}, + }; + } catch { + return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + } +} + +export const actions = { + claim: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const rewardId = fd.get('id') as string; + const message = fd.get('message') as string; + try { + const record = await hono.admin.claimReward(event, famId, rewardId); + if (message) { + await hono.admin.sendMessage(event, famId, record.memberId, message); + } + return { record }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; + } + }, + + revoke: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const completionId = fd.get('id') as string; + try { + await hono.admin.revokeCompletion(event, famId, completionId); + return { revoked: true, id: completionId }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to revoke' }; + } + }, + + trigger: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const configId = fd.get('configId') as string; + const memberId = fd.get('memberId') as string; + if (!configId) return { error: 'Config ID required' }; + try { + const result = await hono.admin.triggerBonusConfig(event, famId, configId, memberId || undefined); + return { records: result.records || [] }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to trigger' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte new file mode 100644 index 0000000..b83869f --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -0,0 +1,813 @@ + + +{#if role === 'parent'} + + + + {#if toast} +
{toast}
+ {/if} + + + + {#each parentMembers as m} + {@const total = totalChoresFor(m.id)} + {@const todays = todayCompletionsFor(m.id)} + {@const done = todays.length} + {@const pct = total > 0 ? Math.round((done / total) * 100) : 0} + {@const s = memberInSummary(m.id)} +
+
+ + {m.name} + Kanban +
+
+ Points: {s?.pointsEarned ?? 0} + Money: £{(s?.moneyEarned ?? 0).toFixed(2)} +
+
+ Today: +
+
+
+ {done}/{total} +
+ {#if done > 0} +
    + {#each todays as c} +
  • + ✅ {choreNameFor(c.assignedChoreId)} +
    { return async (args) => handleResult(args, (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); }); }} class="revoke-form"> + + +
    +
  • + {/each} +
+ {/if} +
+ {/each} +
+ + + {#if manualConfigs().length === 0} +

No manual bonus configs.

+ {:else} + {#each manualConfigs() as bc} + {@const val = bc.rewardType === 'cash' ? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` : bc.rewardType === 'points' ? `${bc.rewardValue} pts` : bc.rewardValue} + {@const targeted = bc.memberId ? parentMembers.find((m: any) => m.id === bc.memberId) : null} +
+
+ {bc.name} + {val} +
+ {#if targeted} +
{ return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}> + + +
+ ■ {targeted.name} + +
+
+ {:else} +
{ return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}> + +
+ + +
+
+ {/if} +
+ {/each} + {/if} +
+ + + {#if claimableRewards().length === 0} +

No outstanding claims

+ {:else} + {#each parentMembers as m} + {@const mc = memberClaimable(m.id)} + {#if mc.length > 0} +
+

{m.name}

+ {#each mc as r} +
{ return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}> + + +
+ {r.label} +
+ {rewardLabel(r)} + +
+
+
+ {/each} +
+

Response:

+
+ {#each responseTags as tag} + + {/each} +
+ { if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; }} + /> +
+
+ {/if} + {/each} + {/if} +
+
+{:else} + + + + + + +

+ { e.stopPropagation(); showColorPicker = !showColorPicker }} + onkeydown={e => e.key === 'Enter' && (showColorPicker = !showColorPicker)}> + {#if editingName} + { + if (e.key === 'Enter') { + editingName = false; + if (nameInput && nameInput !== memberName) { + const old = memberName; + memberName = nameInput; + try { + const res = await fetch('/api/members/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId }, + body: JSON.stringify({ name: nameInput }), + }); + if (!res.ok) memberName = old; + } catch { memberName = old; } + } + } else if (e.key === 'Escape') { + editingName = false; + nameInput = memberName; + } + }} + onblur={() => { editingName = false; nameInput = memberName; }} + autofocus /> + {:else} + { nameInput = memberName; editingName = true; }} + onkeydown={e => e.key === 'Enter' && (nameInput = memberName, editingName = true)}> + {memberName || username} + + {/if} +

+ + {#if showColorPicker} + +
showColorPicker = false}> +
e.stopPropagation()}> +

Pick a color:

+
+ {#each ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899','#78716c'] as color} + + {/each} +
+
+
+ {/if} + + {#if loading} +

Loading...

+ {:else if error} +

{error}

+ {:else} + + +
+
+ Bonus Notices + {bonusNotices.length} + {#if bonusNotices.length > 0} + +{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts + {/if} +
+
+ Total Cash + £{(totalCash / 100).toFixed(2)} +
+
+ Pending Cash + £{(pendingCash / 100).toFixed(2)} +
+
+ Total Points + {totalPoints} +
+
+ + + {#if weeklyTotal > 0} + {@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)} +
+
+ This Week + {weeklyDone}/{weeklyTotal} done +
+
+
+
+
+ {/if} + + + {#if bonusProgresses.length > 0} +
+ {#each bonusProgresses as bp} + {@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0} + {@const hasReward = bp.periodStart ? rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id && r.date >= bp.periodStart && r.date <= bp.periodEnd) : rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id)} + {@const typeClass = bp.config.type === 'threshold' ? 'type-threshold' : bp.config.type === 'count' ? 'type-count' : 'type-manual'} +
+
+ {bp.config.name} + {bp.config.type} +
+
+ {#if bp.criteria > 0} +
+
+
+ {bp.current}/{bp.criteria} {bp.config.type === 'threshold' ? 'pts' : 'chores'} + {/if} +
+ {#if hasReward} + Reward earned 🎉 + {/if} +
+ {/each} +
+ {/if} + + +
+
+

Daily Pending ({dailyPending.length})

+ {#if dailyPending.length === 0} +

All done!

+ {:else} + {#each dailyPending as chore} + + {/each} + {/if} +
+ +
+

Weekly Pending ({weeklyPending.length})

+ {#if weeklyPending.length === 0} +

All done!

+ {:else} + {#each weeklyPending as chore} + + {/each} + {/if} +
+ +
+

Completed Today ({completedToday.length})

+ {#if completedToday.length === 0} +

Nothing yet

+ {:else} + {#each completedToday as c} + {@const chore = assigned.find(a => a.id === c.assignedChoreId)} + {#if chore} + + {/if} + {/each} + {/if} +
+ +
+

Claimable ({claimableRewardsChild.length})

+ {#if claimableRewardsChild.length === 0} +

Nothing to claim

+ {:else} + {#each claimableRewardsChild as r} +
+
+ {r.label} + {rewardLabelChild(r)} +
+
+ {r.rewardType} + +
+
+ {/each} + {/if} +
+
+ + {/if} +
+
+{/if} + + diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts new file mode 100644 index 0000000..091b401 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts @@ -0,0 +1,186 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; + +export async function load(event) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [configs, members, progress, rewards] = await Promise.all([ + hono.admin.bonusConfigs(event, famId), + hono.admin.list(event, 'members', famId), + hono.admin.bonusConfigProgress(event, famId), + hono.admin.rewards(event, famId), + ]); + return { configs, members, progress, rewards }; +} + +export const actions = { + createConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const phase = fd.get('phase') as string; + const data: Record = { + name: fd.get('name'), + target: fd.get('target'), + type: fd.get('type'), + occurrence: fd.get('occurrence'), + rewardType: fd.get('rewardType'), + rewardValue: fd.get('rewardValue'), + phase: phase || 'ready', + }; + const criteriaValue = fd.get('criteriaValue'); + if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; + const period = fd.get('period'); + if (period !== null) data.period = period; + const description = fd.get('description'); + if (description) data.description = description; + const memberId = fd.get('memberId'); + if (memberId) data.memberId = memberId; + try { + const record = await hono.admin.create(event, 'bonus-configs', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' }); + } + }, + + updateConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const data: Record = {}; + const name = fd.get('name'); + if (name) data.name = name; + const target = fd.get('target'); + if (target) data.target = target; + const type = fd.get('type'); + if (type) data.type = type; + const occurrence = fd.get('occurrence'); + if (occurrence) data.occurrence = occurrence; + const rewardType = fd.get('rewardType'); + if (rewardType) data.rewardType = rewardType; + const rewardValue = fd.get('rewardValue'); + if (rewardValue) data.rewardValue = rewardValue; + const criteriaValue = fd.get('criteriaValue'); + if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; + const period = fd.get('period'); + if (period !== null) data.period = period; + const description = fd.get('description'); + if (description) data.description = description; + const memberId = fd.get('memberId'); + if (memberId !== null) data.memberId = memberId || null; + try { + const record = await hono.admin.update(event, 'bonus-configs', famId, id, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' }); + } + }, + + deleteConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + await hono.admin.remove(event, 'bonus-configs', famId, id); + return { deleted: true }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' }); + } + }, + + createFromTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const templateId = fd.get('templateId') as string; + try { + const templates = await hono.admin.bonusConfigs(event, famId); + const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId); + if (!tmpl) return fail(400, { error: 'Template not found' }); + const data: Record = { + name: tmpl.name, + description: tmpl.description || '', + target: tmpl.target, + type: tmpl.type, + occurrence: tmpl.occurrence, + rewardType: tmpl.rewardType, + rewardValue: tmpl.rewardValue, + criteriaValue: tmpl.criteriaValue || 0, + period: tmpl.period || '', + memberId: tmpl.memberId || '', + phase: 'ready', + }; + const record = await hono.admin.create(event, 'bonus-configs', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' }); + } + }, + + assignConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const target = fd.get('target') as string; + const memberId = fd.get('memberId') as string; + try { + const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null }); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' }); + } + }, + + completeConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const phase = fd.get('phase') as string; + try { + const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed'); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' }); + } + }, + + destroyConfig: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + await hono.admin.destroyBonusConfig(event, famId, id); + return { destroyed: true }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' }); + } + }, + + claimReward: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const rewardId = fd.get('id') as string; + try { + await hono.admin.claimReward(event, famId, rewardId); + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; + } + }, + + evaluate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + try { + await hono.admin.evaluateBonusConfig(event, famId); + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to evaluate' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte new file mode 100644 index 0000000..1fd9cec --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -0,0 +1,1071 @@ + + + + + + +
+ +
+ +
+ +
+

Templates

+ + + {#if readyConfigs.length} + {#each readyConfigs as cfg} +
+
+ {cfg.name} +
+ + +
+
+
+ {cfg.target} + {cfg.type} +
+
{formatReward(cfg)}
+ +
+ + + + +
+
+ {/each} + {/if} + + + + {#each templateConfigs as cfg} +
handleDragStart(e, cfg)} + > +
+ {cfg.name} +
+ + +
+
+
+ {cfg.target} + {cfg.type} + {cfg.occurrence} + {#if cfg.period}{cfg.period}{/if} +
+
{formatReward(cfg)}
+ +
+ {/each} +
+ + +
+

Member

+ {#each activeConfigs.filter((c) => c.target === 'individual') as cfg} +
+
+ {cfg.name} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence} +
+
+ {#each members as m} + {#if !cfg.memberId || cfg.memberId === m.id} + {@const p = progressByConfigId.get(cfg.id)?.progress.find((pr: BonusProgress) => pr.memberId === m.id)} + {#if p} +
+ {p.memberName} +
+
+
+ {p.state} +
+ {/if} + {/if} + {/each} +
+ +
+
+ {/each} + {#if activeConfigs.filter((c) => c.target === 'individual').length === 0} +

Drag a template here

+ {/if} +
+ + +
+

Competition

+ {#each activeConfigs.filter((c) => c.target === 'competitive') as cfg} + {@const prog = progressByConfigId.get(cfg.id)} +
+
+ {cfg.name} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence} +
+
+ {#if prog} +
+ {#each [...prog.progress].sort((a, b) => b.current - a.current) as p, i} +
0}> + #{i + 1} + {p.memberName} +
+
+
+ {p.state} +
+ {/each} +
+ {/if} +
+ +
+
+ {/each} + {#if activeConfigs.filter((c) => c.target === 'competitive').length === 0} +

Drag a template here

+ {/if} +
+ + +
+

Collaboration

+ {#each activeConfigs.filter((c) => c.target === 'collaborative') as cfg} + {@const prog = progressByConfigId.get(cfg.id)} +
+
+ {cfg.name} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence} +
+
+ {#if prog} + {@const team = prog.progress[0]} + {#if team} +
+ {team.memberName} +
+
+
+ {team.current}/{team.criteriaValue} + {cfg.type === 'threshold' ? 'pts' : 'chores'} + {team.state} +
+ {/if} + {/if} +
+ +
+
+ {/each} + {#if activeConfigs.filter((c) => c.target === 'collaborative').length === 0} +

Drag a template here

+ {/if} +
+
+ + +{#if completedConfigs.length} +
+

Completed

+
+ {#each completedConfigs as cfg} +
+
+ {cfg.name} +
+ {cfg.target} + {cfg.type} +
+
+
{formatReward(cfg)}
+
+ +
+ + + +
+
+ + +
+
+
+ {/each} +
+
+{/if} + + +{#if showCreateModal} +
(showCreateModal = false)} role="presentation"> + +
+{/if} + + +{#if showEditModal && editingConfig} +
{ + showEditModal = false; + editingConfig = null; + }} + role="presentation" + > + +
+{/if} +
+
+ + diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts new file mode 100644 index 0000000..89490f5 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts @@ -0,0 +1,140 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; + +export async function load(event) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [templates, members, assigned] = await Promise.all([ + hono.admin.list(event, 'chore-templates', famId), + hono.admin.list(event, 'members', famId), + hono.admin.list(event, 'assigned-chores', famId), + ]); + return { templates, members, assigned }; +} + +export const actions = { + createTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const data = { + name: fd.get('name'), + defaultFrequency: fd.get('defaultFrequency'), + defaultType: fd.get('defaultType'), + defaultValue: parseFloat(fd.get('defaultValue') as string) || 0, + }; + if (!data.name || !data.defaultFrequency || !data.defaultType) { + return fail(400, { error: 'Name, frequency, and type are required' }); + } + try { + const record = await hono.admin.create(event, 'chore-templates', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' }); + } + }, + + updateTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const defaultFrequency = fd.get('defaultFrequency') as string; + const defaultType = fd.get('defaultType') as string; + const defaultValue = parseFloat(fd.get('defaultValue') as string) || 0; + const data = { + name: fd.get('name'), + defaultFrequency, + defaultType, + defaultValue, + }; + try { + const record = await hono.admin.update(event, 'chore-templates', famId, id, data); + const allAssigned = await hono.admin.list(event, 'assigned-chores', famId); + if (Array.isArray(allAssigned)) { + const toUpdate = allAssigned.filter((a: any) => a.templateId === id); + await Promise.all(toUpdate.map((a: any) => + hono.admin.update(event, 'assigned-chores', famId, a.id, { + frequency: defaultFrequency, + type: defaultType, + value: defaultValue, + }) + )); + } + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' }); + } + }, + + deleteTemplate: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + const record = await hono.admin.remove(event, 'chore-templates', famId, id); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' }); + } + }, + + assignChore: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const data = { + memberId: fd.get('memberId'), + templateId: fd.get('templateId'), + frequency: fd.get('frequency'), + type: fd.get('type'), + value: parseFloat(fd.get('value') as string) || 0, + customName: fd.get('customName') || undefined, + }; + if (!data.memberId || !data.templateId || !data.frequency || !data.type) { + return fail(400, { error: 'Member, template, frequency, and type are required' }); + } + try { + const record = await hono.admin.create(event, 'assigned-chores', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' }); + } + }, + + updateAssignedChore: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + const data: Record = {}; + const frequency = fd.get('frequency'); + const type = fd.get('type'); + const value = fd.get('value'); + const customName = fd.get('customName'); + if (frequency) data.frequency = frequency; + if (type) data.type = type; + if (value) data.value = parseFloat(value as string) || 0; + data.customName = (customName as string) || undefined; + try { + const record = await hono.admin.update(event, 'assigned-chores', famId, id, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' }); + } + }, + + unassignChore: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + try { + const record = await hono.admin.remove(event, 'assigned-chores', famId, id); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' }); + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte new file mode 100644 index 0000000..f1af52c --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -0,0 +1,402 @@ + + + + +{#if form?.error} +

{form.error}

+{/if} + + + +
+ +
+
+
+

Templates

+ {#each templates as t} +
handleDragStart(e, t.id)} + > +
openEditTemplate(t)} role="button" tabindex="0"> + {t.name} + {t.defaultFrequency} + {t.defaultType} +
+
+ {t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue} +
+
+ +
{ return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } }; }}> + + +
+
+
+ {/each} + {#if templates.length === 0} +

No templates yet

+ {/if} +
+ + {#each members as m} +
handleDrop(e, m.id)} + > +

+ + {m.name} +

+ {#each assignedForMember(m.id) as a} + {@const tName = templateName(a.templateId)} +
openEdit(a)} role="button" tabindex="0"> +
+ {a.customName || tName} + {a.frequency} + {a.type} +
+
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}
+
e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}> + + +
+
+ {/each} + {#if assignedForMember(m.id).length === 0} +

Drop a chore here

+ {/if} +
+ {/each} +
+ +
+ + +{#if showCreateModal} +
showCreateModal = false} role="presentation"> + +
+{/if} + + +{#if showEditTemplateModal && editingTemplate} + +{/if} + + +{#if showEditModal && editingAssignment} + +{/if} + + diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts new file mode 100644 index 0000000..71ec924 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts @@ -0,0 +1,99 @@ +import { redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; +import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export async function load(event) { + const session = event.locals.session; + const famSlug = event.params.fam; + const username = event.params.username; + + // Parent (session auth) — profile lives in fam_admins + if (session) { + const famId = session.famId; + if (session.memberName && session.memberName !== username) { + throw redirect(303, `/${famSlug}/${session.memberName}/preferences`); + } + try { + const me = await hono.admin.getProfile(event, famId); + return { + verified: true, token: '', memberId: me.id, famId, + memberName: me.name, memberColor: me.color, email: me.email || '', + session: true, + }; + } catch { + return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; + } + } + + // Child (device token) — profile lives in members + const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; + if (!deviceToken) { + return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; + } + + try { + const res = await fetch(`${HONO_URL}/api/members/verify-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceToken, famSlug }), + }); + const data = await res.json(); + if (!res.ok || data.name !== username) { + return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; + } + return { + verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId, + memberName: data.name, memberColor: data.color, email: data.email || '', session: false, + }; + } catch { + return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' }; + } +} + +export const actions = { + update: async (event) => { + const session = event.locals.session; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + const color = fd.get('color') as string; + const email = fd.get('email') as string; + + if (session) { + const famId = session.famId; + try { + const data: Record = {}; + if (name) data.name = name; + if (color) data.color = color; + data.email = email || ''; + const me = await hono.admin.updateProfile(event, famId, data); + return { success: true, name: me.name, color: me.color, email: me.email }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Update failed' }; + } + } + + const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; + const famSlug = event.params.fam; + if (!deviceToken) return { error: 'Not authenticated' }; + + try { + const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceToken, famSlug }), + }); + const verify = await verifyRes.json(); + if (!verifyRes.ok) return { error: 'Verification failed' }; + const res = await fetch(`${HONO_URL}/api/members/me`, { + method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, color }), + }); + const data = await res.json(); + if (!res.ok) return { error: data.error || 'Update failed' }; + return { success: true, name: data.name, color: data.color }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Update failed' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.svelte b/frontend/src/routes/[fam]/[username]/preferences/+page.svelte new file mode 100644 index 0000000..65d3b68 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.svelte @@ -0,0 +1,99 @@ + + + + +{#if !data.verified} + + +

+ Not authenticated. Use the link from your invite. +

+
+
+{:else} + + +
+
+ + +
+ +
+ +
+ {#each colors as c} + + {/each} +
+ +
+ +
+ + {#if data.session} + + {:else} + + {/if} +
+ + {#if form?.error} +

{form.error}

+ {/if} + {#if form?.success} +

Saved!

+ {/if} + + +
+
+ + +

+ To change your device or sign in on a new device, use your invite link. +

+

+ Contact your family admin to change your token or reset your account. +

+
+
+{/if} diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts new file mode 100644 index 0000000..9939d46 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; + +export async function load(event) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [rewards, members] = await Promise.all([ + hono.admin.rewards(event, famId), + hono.admin.list(event, 'members', famId), + ]); + return { rewards, members }; +} + +export const actions = { + claim: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const rewardId = fd.get('id') as string; + try { + const record = await hono.admin.claimReward(event, famId, rewardId); + return { record }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; + } + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte new file mode 100644 index 0000000..147373d --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -0,0 +1,137 @@ + + + + +{#if toast} +
{toast}
+{/if} + + + + {#if rewards.length === 0} +

No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.

+ {:else} + + + + + + + + + + + + + {#each sorted as r} + {@const outstanding = isOutstanding(r)} + + + + + + + + + {/each} + + + + + + + + + + + +
DateMemberDescriptionAmountStatus
{r.date?.slice(0, 10) || '—'} + + {memberName(r.memberId)} + {r.label}{rewardAmount(r)} + + {rewardStatus(r)} + + + {#if outstanding} +
{ return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}> + + +
+ {/if} +
Outstanding cash£{(totalOutstanding / 100).toFixed(2)}
+ {/if} +
+
+ + diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts new file mode 100644 index 0000000..bfdff35 --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts @@ -0,0 +1,57 @@ +import { redirect } from '@sveltejs/kit'; +import { hono } from '$lib/server/hono'; +import type { RequestEvent } from '@sveltejs/kit'; + +export async function load(event: RequestEvent) { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const [members, fam] = await Promise.all([ + hono.admin.list(event, 'members', famId), + hono.admin.fam(event, famId), + ]); + return { members, fam }; +} + +export const actions = { + regenInvite: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode }; + }, + + addMember: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + if (!name) return { error: 'Name required' }; + await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' }); + }, + + renameFam: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + if (!name) return { error: 'Name required' }; + return await hono.admin.renameFam(event, famId, name); + }, + + deleteMember: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const id = fd.get('id') as string; + if (!id) return { error: 'Member ID required' }; + await hono.admin.remove(event, 'members', famId, id); + }, + + updatePayday: async (event: RequestEvent) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const payday = parseInt(fd.get('payday') as string, 10); + if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' }; + return await hono.admin.updatePayday(event, famId, payday); + }, +}; diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte new file mode 100644 index 0000000..16670bc --- /dev/null +++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte @@ -0,0 +1,171 @@ + + + + + + +
+ + +
+
+ + +
+ + +
+ +
    + {#each members as m} +
  • + + {m.name} + {m.deviceToken ? '' : ' (pending join)'} + Kanban +
    + + +
    +
  • + {/each} +
+
+ + +
+ + +
+

The week starts on this day for tracking weekly chores and bonus cycles.

+
+ + +

{fam?.inviteCode || '...'}

+ +
+ + +
+

Re-joining a member will disconnect their old device.

+ +

{selectedInviteUrl}

+ +
+ + + + +
+ +
+
+ + {#if showQR && qrDataUrl} + QR Code + {/if} +
+ + +

Send an email invitation for another parent to join as an admin.

+
+ + +
+

They will set up their own password on first login.

+
+ + +
+ + +
+
+
+ + diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..8d870e4 --- /dev/null +++ b/frontend/src/routes/admin/+page.server.ts @@ -0,0 +1,23 @@ +import { pbAdmin } from '$lib/server/pb-admin'; + +export async function load() { + const fams = await pbAdmin.getList('fams'); + const famsWithStats = await Promise.all(fams.map(async (fam: any) => { + const [members, rewards] = await Promise.all([ + pbAdmin.getList('members', `famId = '${fam.id}'`), + pbAdmin.getList('rewards', `famId = '${fam.id}'`), + ]); + return { + id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode, + memberCount: members.length, + claimedRewards: (rewards as any[]).filter((r: any) => r.claimed).length, + totalRewards: rewards.length, + }; + })); + + const totalFams = fams.length; + const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0); + const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0); + + return { fams: famsWithStats, totalFams, totalMembers, totalRewards }; +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..cc08a88 --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,37 @@ + + +

Super Admin

+ +
+
{data.totalFams} Families
+
{data.totalMembers} Members
+
{data.totalRewards} Rewards
+
+ +

Families

+ + + + {#each data.fams as fam} + + + + + + + + {/each} + +
NameSlugMembersRewardsInvite Code
{fam.name}{fam.slug}{fam.memberCount}{fam.claimedRewards}/{fam.totalRewards}{fam.inviteCode}
+ + diff --git a/frontend/src/routes/join/[code]/+page.server.ts b/frontend/src/routes/join/[code]/+page.server.ts new file mode 100644 index 0000000..bfd9c19 --- /dev/null +++ b/frontend/src/routes/join/[code]/+page.server.ts @@ -0,0 +1,24 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { joinMember, setDeviceTokenCookie } from '$lib/server/auth'; + +export const actions = { + default: async (event) => { + const code = event.params.code; + const fd = await event.request.formData(); + const name = fd.get('name') as string; + + if (!name) { + return fail(400, { error: 'Name is required' }); + } + + const deviceToken = crypto.randomUUID(); + + try { + const result = await joinMember(code, name, deviceToken); + setDeviceTokenCookie(event, deviceToken); + throw redirect(303, `/${result.famSlug}/${result.name}`); + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' }); + } + }, +}; diff --git a/frontend/src/routes/join/[code]/+page.svelte b/frontend/src/routes/join/[code]/+page.svelte new file mode 100644 index 0000000..1fd3601 --- /dev/null +++ b/frontend/src/routes/join/[code]/+page.svelte @@ -0,0 +1,29 @@ + + +

Join family

+ +

Enter your name to join. It must match a member slot created by your admin.

+ +
+ {#if form?.error} +

{form.error}

+ {/if} + + + + +
+ + diff --git a/frontend/src/routes/join/[code]/[member]/+page.server.ts b/frontend/src/routes/join/[code]/[member]/+page.server.ts new file mode 100644 index 0000000..9b1b6b6 --- /dev/null +++ b/frontend/src/routes/join/[code]/[member]/+page.server.ts @@ -0,0 +1,24 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { setDeviceTokenCookie } from '$lib/server/auth'; +import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; + +const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; + +export const actions = { + default: async (event) => { + const code = event.params.code; + const name = event.params.member; + + const res = await fetch(`${HONO_URL}/api/members/direct-join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode: code, name }), + }); + const data = await res.json(); + if (!res.ok) return fail(400, { error: data.error || 'Join failed' }); + + setDeviceTokenCookie(event, data.deviceToken); + + throw redirect(303, `/${data.famSlug}/${data.name}`); + }, +}; diff --git a/frontend/src/routes/join/[code]/[member]/+page.svelte b/frontend/src/routes/join/[code]/[member]/+page.svelte new file mode 100644 index 0000000..0cb1f4c --- /dev/null +++ b/frontend/src/routes/join/[code]/[member]/+page.svelte @@ -0,0 +1,24 @@ + + +

Join family

+ +{#if form?.error} +

{form.error}

+{:else} +

Click below to join as {memberName}

+{/if} + +
+ +
+ + diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 0000000..e4d8852 --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,33 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth'; + +export const actions = { + default: async (event) => { + + const fd = await event.request.formData(); + const email = fd.get('email') as string; + const password = fd.get('password') as string; + + if (!email || !password) { + return fail(400, { error: 'Email and password required', email }); + } + + let result: any; + try { + result = await login(email, password); + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email }); + } + + setSessionCookie(event, { + famId: result.famId, + userId: result.userId, + famSlug: result.famSlug, + memberName: result.memberName, + role: result.role, + }); + setPbTokenCookie(event, result.token); + + throw redirect(303, `/${result.famSlug}/${result.memberName}`); + }, +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..d9ec0b8 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,35 @@ + + +

Log in

+ +
+ {#if form?.error} +

{form.error}

+ {/if} + + + + + + +
+ +

Don't have a family yet? Create one

+ + diff --git a/frontend/src/routes/logout/+page.server.ts b/frontend/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..1a179b9 --- /dev/null +++ b/frontend/src/routes/logout/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +import { clearSession } from '$lib/server/auth'; + +export function load(event) { + clearSession(event); + throw redirect(303, '/'); +} + +export const actions = { + default: (event) => { + clearSession(event); + throw redirect(303, '/'); + }, +}; diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts new file mode 100644 index 0000000..830d047 --- /dev/null +++ b/frontend/src/routes/signup/+page.server.ts @@ -0,0 +1,35 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth'; + +export const actions = { + default: async (event) => { + const fd = await event.request.formData(); + const email = fd.get('email') as string; + const password = fd.get('password') as string; + const famName = fd.get('famName') as string; + const parentName = fd.get('parentName') as string; + + if (!email || !password || !famName) { + return fail(400, { error: 'All fields required', email, famName }); + } + if (password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters', email, famName }); + } + + try { + const result = await signup(email, password, famName, parentName || email.split('@')[0]); + setSessionCookie(event, { + famId: result.famId, + userId: result.userId, + famSlug: result.famSlug, + memberName: result.memberName, + role: result.role, + }); + setPbTokenCookie(event, result.token); + throw redirect(303, `/${result.famSlug}/${result.memberName}`); + } catch (e) { + if (e instanceof redirect) throw e; + return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName }); + } + }, +}; diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte new file mode 100644 index 0000000..1d1e8b4 --- /dev/null +++ b/frontend/src/routes/signup/+page.svelte @@ -0,0 +1,47 @@ + + +

Create your family

+ +
+ {#if form?.error} +

{form.error}

+ {/if} + + + + + + + + + + +
+ +

Already have an account? Log in

+ + diff --git a/proxy/.gitignore b/proxy/.gitignore new file mode 100644 index 0000000..9b1c8b1 --- /dev/null +++ b/proxy/.gitignore @@ -0,0 +1 @@ +/dist diff --git a/proxy/dist/index.d.ts b/proxy/dist/index.d.ts deleted file mode 100644 index e26a57a..0000000 --- a/proxy/dist/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/proxy/dist/index.d.ts.map b/proxy/dist/index.d.ts.map deleted file mode 100644 index 535b86d..0000000 --- a/proxy/dist/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/proxy/dist/index.js b/proxy/dist/index.js deleted file mode 100644 index cbecddd..0000000 --- a/proxy/dist/index.js +++ /dev/null @@ -1,35 +0,0 @@ -import { serve } from '@hono/node-server'; -import { Hono } from 'hono'; -const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8090'; -const PB_EMAIL = process.env.PB_SUPERUSER_EMAIL || ''; -const PB_PASSWORD = process.env.PB_SUPERUSER_PASSWORD || ''; -const DEBUG_RECORD_ID = process.env.DEBUG_RECORD_ID || ''; -const app = new Hono(); -app.get('/api/health', (c) => c.json({ status: 'ok' })); -app.post('/api/debug/increment-hono', async (c) => { - const auth = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }) - }); - const { token } = await auth.json(); - const record = await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, { - headers: { Authorization: `Bearer ${token}` } - }); - const data = await record.json(); - const current = data.hono_count || 0; - await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ hono_count: current + 1 }) - }); - return c.json({ ok: true }); -}); -const port = parseInt(process.env.PROXY_PORT || '3456', 10); -serve({ fetch: app.fetch, port }, (info) => { - console.log(`Hono proxy listening on http://localhost:${info.port}`); -}); -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/proxy/dist/index.js.map b/proxy/dist/index.js.map deleted file mode 100644 index 36de1d6..0000000 --- a/proxy/dist/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,uBAAuB,CAAC;AAC7D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC;AACtD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC;AAC5D,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,CAAC;AAE1D,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;AAEvB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AAExD,GAAG,CAAC,IAAI,CAAC,2BAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;IACjD,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,iDAAiD,EAAE;QACpF,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;KACnE,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;IAEpC,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACxF,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE;KAC7C,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACjC,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;IAErC,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACzE,MAAM,EAAE,OAAO;QACf,OAAO,EAAE;YACR,cAAc,EAAE,kBAAkB;YAClC,aAAa,EAAE,UAAU,KAAK,EAAE;SAChC;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,EAAE,CAAC;KACjD,CAAC,CAAC;IAEH,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7B,CAAC,CAAC,CAAC;AAEH,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;AAE5D,KAAK,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,EAAE;IAC1C,OAAO,CAAC,GAAG,CAAC,4CAA4C,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;AACtE,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/proxy/package.json b/proxy/package.json index 19da01e..679e3a3 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "seed": "tsx scripts/seed.ts" }, "dependencies": { "@hono/node-server": "^1.13.0", diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts new file mode 100644 index 0000000..2801cd2 --- /dev/null +++ b/proxy/scripts/seed.ts @@ -0,0 +1,314 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +interface FieldDef { + name: string + type: string + required?: boolean + unique?: boolean + max?: number + min?: number + values?: string[] + maxSelect?: number + collectionId?: string + cascadeDelete?: boolean +} + +interface CollectionDef { + name: string + type: string + fields: FieldDef[] + listRule?: string | null + viewRule?: string | null + createRule?: string | null + updateRule?: string | null + deleteRule?: string | null +} + +async function getSuperadminToken(): Promise { + const res = await fetch( + `${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), + }, + ); + const data = await res.json(); + if (!res.ok) throw new Error(`Auth failed: ${JSON.stringify(data)}`); + return data.token; +} + +async function createCollection( + token: string, + col: CollectionDef, +): Promise { + const existing = await fetch( + `${PB_ENDPOINT}/api/collections?filter=name='${col.name}'`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + const existingData = await existing.json(); + if (existingData?.items?.length > 0) { + console.log(` ↳ Already exists: ${col.name}`); + return existingData.items[0].id; + } + + const res = await fetch(`${PB_ENDPOINT}/api/collections`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(col), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); + console.log(` ✓ Created: ${col.name}`); + return data.id; +} + +function text(name: string, required = false): FieldDef { + return { name, type: "text", required }; +} + +function uniqueText(name: string): FieldDef { + return { name, type: "text", required: true, unique: true }; +} + +function number(name: string, required = false): FieldDef { + return { name, type: "number", required }; +} + +function bool(name: string): FieldDef { + return { name, type: "bool" }; +} + +function date(name: string): FieldDef { + return { name, type: "date" }; +} + +function jsonField(name: string): FieldDef { + return { name, type: "json" }; +} + +function select(name: string, values: string[], required = false): FieldDef { + return { name, type: "select", required, values, maxSelect: 1 }; +} + +function rel(name: string, collectionId: string, required = false): FieldDef { + return { + name, + type: "relation", + required, + collectionId, + maxSelect: 1, + cascadeDelete: false, + }; +} + +async function main() { + console.log("Connecting to PB at", PB_ENDPOINT); + const token = await getSuperadminToken(); + console.log("Authenticated as superadmin\n"); + + const ids: Record = {}; + + // ── Pass 1: Independent collections ── + console.log("--- Pass 1: Independent collections ---"); + + ids.fams = await createCollection(token, { + name: "fams", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + text("name", true), + uniqueText("slug"), + text("inviteCode"), + text("stripeCustomerId"), + jsonField("featureFlags"), + ], + }); + + // ── Pass 2: Collections that reference fams ── + console.log("\n--- Pass 2: fam-scoped collections ---"); + + ids.settings = await createCollection(token, { + name: "settings", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("webhookUrl"), + ], + }); + + ids.members = await createCollection(token, { + name: "members", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("name", true), + text("color"), + text("deviceToken"), + text("deviceTokenHint"), + ], + }); + + ids.chore_templates = await createCollection(token, { + name: "chore_templates", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("name", true), + text("description"), + select("defaultFrequency", ["daily", "weekly"], true), + select("defaultType", ["points", "money"], true), + number("defaultValue", true), + ], + }); + + ids.fam_admins = await createCollection(token, { + name: "fam_admins", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("userId", true), + text("email", true), + text("name"), + text("color"), + ], + }); + + ids.bonus_configs = await createCollection(token, { + name: "bonus_configs", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("name", true), + text("description"), + select("target", ["individual", "competitive"], true), + select("type", ["threshold", "count", "manual"], true), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", true), + number("criteriaValue"), + select("period", ["weekly", "monthly"]), + select("status", ["active", "archived"], true), + ], + }); + + // ── Pass 3: Collections with member/template deps ── + console.log("\n--- Pass 3: Nested collections ---"); + + ids.weekly_history = await createCollection(token, { + name: "weekly_history", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + date("weekStart"), + number("pointsEarned"), + number("moneyEarned"), + number("choresCompleted"), + number("bonusEarned"), + ], + }); + + ids.rewards = await createCollection(token, { + name: "rewards", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + rel("bonusConfigId", ids.bonus_configs!), + text("label", true), + number("value", true), + select("rewardType", ["cash", "prize", "points"], true), + bool("claimed"), + date("claimedAt"), + text("date"), + ], + }); + + ids.assigned_chores = await createCollection(token, { + name: "assigned_chores", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + rel("templateId", ids.chore_templates!, true), + select("frequency", ["daily", "weekly"], true), + select("type", ["points", "money"], true), + number("value", true), + text("customName"), + ], + }); + + ids.completions = await createCollection(token, { + name: "completions", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + rel("memberId", ids.members!, true), + rel("assignedChoreId", ids.assigned_chores!, true), + date("date"), + ], + }); + + console.log("\n✅ All collections created successfully"); + console.log("Collection IDs:", ids); +} + +main().catch((err) => { + console.error("Seed failed:", err); + process.exit(1); +}); diff --git a/proxy/scripts/test-admin.ts b/proxy/scripts/test-admin.ts new file mode 100644 index 0000000..df6545b --- /dev/null +++ b/proxy/scripts/test-admin.ts @@ -0,0 +1,99 @@ +const HONO = "http://192.168.1.225:3456"; + +async function main() { + // Signup + const signup = await fetch(`${HONO}/api/admin/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "admin2@test.com", password: "password1234", famName: "Admin Test" }), + }); + const s = await signup.json(); + console.log("Signup:", s.famId, s.famSlug); + const famId = s.famId; + + // Test admin authenticated calls + const headers = { + "x-session-famid": famId, + "x-session-role": "admin", + "x-session-userid": s.userId, + "Content-Type": "application/json", + }; + + // Create a chore template + console.log("\nCreate template..."); + const tmpl = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { + method: "POST", headers, body: JSON.stringify({ + name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 10, + }), + }); + const t = await tmpl.json(); + console.log("Template:", t.id, t.name); + + // List templates + console.log("\nList templates..."); + const list = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { headers }); + console.log("Templates:", (await list.json()).length); + + // Create a member + console.log("\nCreate member..."); + const mem = await fetch(`${HONO}/api/admin/${famId}/members`, { + method: "POST", headers, body: JSON.stringify({ name: "Kid", color: "#6366f1" }), + }); + const m = await mem.json(); + console.log("Member:", m.id, m.name); + + // Assign chore + console.log("\nAssign chore..."); + const assign = await fetch(`${HONO}/api/admin/${famId}/assigned-chores`, { + method: "POST", headers, body: JSON.stringify({ + memberId: m.id, templateId: t.id, frequency: "daily", type: "points", value: 10, + }), + }); + const a = await assign.json(); + console.log("Assigned:", a.id); + + // Test device token auth + console.log("\nTest completion toggle with device token..."); + const devHeaders = { + "x-device-token": "dev-token-test", + "x-device-famid": famId, + "Content-Type": "application/json", + }; + // First need to join with this device token + const join = await fetch(`${HONO}/api/members/join`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inviteCode: s.famSlug, ...(await (await fetch(`${HONO}/api/admin/${famId}/members`, { headers })).json()).length > 1 ? {} : { name: "Test", deviceToken: "dev-token-test" } }), + }); + // Actually, just use the member we already created but we can't use device token with it since it has no token + // Let's join a new one + console.log("Joining with device token..."); + const j = await fetch(`${HONO}/api/members/join`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inviteCode: "TEST", name: "Test Kid", deviceToken: "dev-token-test" }), + }); + const joinData = await j.json(); + console.log("Join result:", JSON.stringify(joinData)); + + if (joinData.famId) { + // Toggle completion + console.log("\nToggle completion..."); + const toggle = await fetch(`${HONO}/api/completions/toggle`, { + method: "POST", + headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" }, + body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }), + }); + console.log("Toggle:", await toggle.json()); + + // Toggle again (should undo) + const toggle2 = await fetch(`${HONO}/api/completions/toggle`, { + method: "POST", + headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" }, + body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }), + }); + console.log("Toggle undo:", await toggle2.json()); + } + + console.log("\n✅ All admin tests passed"); +} + +main().catch(console.error); diff --git a/proxy/scripts/test-auth.ts b/proxy/scripts/test-auth.ts new file mode 100644 index 0000000..3645911 --- /dev/null +++ b/proxy/scripts/test-auth.ts @@ -0,0 +1,63 @@ +const HONO = "http://192.168.1.225:3456"; + +async function main() { + // 1. Signup + console.log("=== Signup ==="); + const signup = await fetch(`${HONO}/api/admin/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "test@fam.com", password: "password123", famName: "Test Fam" }), + }); + const signupData = await signup.json(); + console.log("Signup:", JSON.stringify(signupData, null, 2)); + + // 2. Login + console.log("\n=== Login ==="); + const login = await fetch(`${HONO}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "test@fam.com", password: "password123" }), + }); + const loginData = await login.json(); + console.log("Login:", JSON.stringify(loginData, null, 2)); + + // 3. Get invite code from fams directly via PB + const pbTokenRes = await fetch("http://192.168.1.225:8090/api/collections/_superusers/auth-with-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: "debug@famchamp.dev", password: "debug123" }), + }); + const pbTokenData = await pbTokenRes.json(); + const pbSuperToken = pbTokenData.token; + + const famsRes = await fetch("http://192.168.1.225:8090/api/collections/fams/records?sort=-created", { + headers: { Authorization: `Bearer ${pbSuperToken}` }, + }); + const famsData = await famsRes.json(); + const fam = famsData.items[0]; + console.log("\n=== Fam ==="); + console.log("Fam:", JSON.stringify(fam, null, 2)); + console.log("Invite code:", fam.inviteCode); + + // 4. Join as member + console.log("\n=== Join ==="); + const join = await fetch(`${HONO}/api/members/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inviteCode: fam.inviteCode, name: "Kid", deviceToken: "dev-token-xyz" }), + }); + const joinData = await join.json(); + console.log("Join:", JSON.stringify(joinData, null, 2)); + + // 5. Verify member + console.log("\n=== Verify ==="); + const verify = await fetch(`${HONO}/api/members/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ famId: fam.id, deviceToken: "dev-token-xyz" }), + }); + const verifyData = await verify.json(); + console.log("Verify:", JSON.stringify(verifyData, null, 2)); +} + +main().catch(console.error); diff --git a/proxy/src/index.ts b/proxy/src/index.ts index 05a0f32..32e00e2 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -1,56 +1,1086 @@ +import crypto from "node:crypto"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; - -import { - SERVER_IP, - PB_PORT, - PB_EMAIL, - PB_PASSWORD, - DEBUG_RECORD_ID, -} from "../../config.ts"; +import { pb } from "./pb.ts"; +import { migrate } from "./migrate.ts"; const app = new Hono(); -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; app.get("/api/health", (c) => c.json({ status: "ok" })); -app.post("/api/increment-hono", async (c) => { - const auth = await fetch( - `${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), - }, - ); - const { token } = await auth.json(); +function handleError(c: any, err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + return c.json({ error: msg }, 500); +} - const record = await fetch( - `${PB_ENDPOINT}/api/collections/debug/records/${DEBUG_RECORD_ID}`, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ); - const data = await record.json(); - const current = data.hono_count || 0; +// ── Helpers ───────────────────────────────────────────── - await fetch( - `${PB_ENDPOINT}/api/collections/debug/records/${DEBUG_RECORD_ID}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ hono_count: current + 1 }), - }, - ); +function weekStart(payday: number = 1): string { + const d = new Date(); + const day = d.getDay(); + const diff = d.getDate() - ((day - payday + 7) % 7); + d.setDate(diff); + return d.toISOString().slice(0, 10); +} - return c.json({ ok: true }); +function monthStart(month?: string): string { + if (month) return `${month}-01`; + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; +} + +function monthEnd(month?: string): string { + if (!month) { + const d = new Date(); + month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + } + const [y, m] = month.split("-").map(Number); + const lastDay = new Date(y, m, 0).getDate(); + return `${month}-${String(lastDay).padStart(2, "0")}`; +} + +function periodStart(period: string, payday?: number): string { + if (period === "weekly") return weekStart(payday); + if (period === "monthly") return monthStart(); + return weekStart(payday); +} + +function periodEnd(period: string, start: string): string { + if (period === "weekly") { + const d = new Date(start); + d.setDate(d.getDate() + 6); + return d.toISOString().slice(0, 10); + } + if (period === "monthly") { + const m = start.slice(0, 7); + return monthEnd(m); + } + return start; +} + +async function getFamPayday(famId: string): Promise { + try { + const fam = await pb.getList("fams", `id = '${famId}'`); + const p = fam.items?.[0]?.payday; + return p !== undefined && p !== null ? Number(p) : 1; + } catch { + return 1; + } +} + +// ── Middleware ───────────────────────────────────────────── + +async function requireAdmin(c: any, next: any) { + const famId = c.req.header("x-session-famid"); + const userId = c.req.header("x-session-userid"); + if (!famId || !userId) { + return c.json({ error: "Unauthorized" }, 401); + } + const admins = await pb.getList("fam_admins", `famId = '${famId}' && userId = '${userId}'`); + if (!admins.items?.length) { + return c.json({ error: "Unauthorized" }, 401); + } + c.set("famId", famId); + return next(); +} + +async function requireDeviceToken(c: any, next: any) { + const deviceToken = c.req.header("x-device-token"); + const famId = c.req.header("x-device-famid"); + if (!famId || !deviceToken) { + return c.json({ error: "x-device-token and x-device-famid headers required" }, 400); + } + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList("members", `famId = '${famId}' && deviceToken = '${hashHex}'`); + const member = members.items?.[0]; + if (!member) return c.json({ error: "Invalid device token" }, 401); + c.set("famId", famId); + c.set("memberId", member.id); + return next(); +} + +// ── Auth routes ─────────────────────────────────────────── + +app.post("/api/admin/signup", async (c) => { + try { + const { email, password, famName, parentName } = await c.req.json(); + if (!email || !password || !famName) { + return c.json({ error: "email, password, famName required" }, 400); + } + const slug = famName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); + const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + const user = await pb.createUser(email, password); + const fam = await pb.create("fams", { name: famName, slug, inviteCode, featureFlags: {} }); + const adminName = parentName || email.split("@")[0]; + const admin = await pb.create("fam_admins", { famId: fam.id, userId: user.id, email, name: adminName, color: "#6366f1" }); + await pb.create("settings", { famId: fam.id }); + const authResult = await pb.authWithPassword(email, password); + return c.json({ + famId: fam.id, famSlug: slug, userId: user.id, token: authResult.token, + memberId: admin.id, memberName: admin.name, memberColor: admin.color, role: "parent", + }); + } catch (err) { return handleError(c, err); } }); +app.post("/api/admin/login", async (c) => { + try { + const { email, password } = await c.req.json(); + if (!email || !password) return c.json({ error: "email, password required" }, 400); + const authResult = await pb.authWithPassword(email, password); + const userId = authResult.record.id; + const admins = await pb.getList("fam_admins", `userId = '${userId}'`); + const admin = admins.items?.[0]; + if (!admin) return c.json({ error: "No fam found for user" }, 404); + const fams = await pb.getList("fams", `id = '${admin.famId}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + // Update fam_admins name/color on login + const defaultName = email.split("@")[0]; + const adminPatch: Record = { name: defaultName, color: "#6366f1" }; + if (!admin.email) adminPatch.email = email; + const updatedAdmin = await pb.update("fam_admins", admin.id, adminPatch); + return c.json({ + famId: fam.id, famSlug: fam.slug, userId, token: authResult.token, + memberId: updatedAdmin.id, memberName: updatedAdmin.name, memberColor: updatedAdmin.color, + role: "parent", + }); + } catch (err) { return handleError(c, err); } +}); + +async function joinMemberFlow(famId: string, name: string, deviceToken: string) { + const existing = await pb.getList("members", `famId = '${famId}' && name = '${name}'`); + const slot = existing.items?.[0]; + if (!slot) throw new Error(`No member named "${name}" in this family`); + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const tokenHint = deviceToken.substring(0, 8); + await pb.update("members", slot.id, { deviceToken: hashHex, deviceTokenHint: tokenHint }); + const newCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + await pb.update("fams", famId, { inviteCode: newCode }); + return { memberId: slot.id, deviceToken, name: slot.name, inviteCode: newCode }; +} + +app.post("/api/members/join", async (c) => { + try { + const { inviteCode, name, deviceToken } = await c.req.json(); + if (!inviteCode || !name || !deviceToken) return c.json({ error: "inviteCode, name, deviceToken required" }, 400); + const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Invalid invite code" }, 404); + const result = await joinMemberFlow(fam.id, name, deviceToken); + return c.json({ famId: fam.id, famSlug: fam.slug, ...result }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/direct-join", async (c) => { + try { + const { inviteCode, name } = await c.req.json(); + if (!inviteCode || !name) return c.json({ error: "inviteCode, name required" }, 400); + const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Invalid invite code" }, 404); + const deviceToken = crypto.randomUUID(); + const result = await joinMemberFlow(fam.id, name, deviceToken); + return c.json({ famId: fam.id, famSlug: fam.slug, ...result }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/verify", async (c) => { + try { + const { famId, deviceToken } = await c.req.json(); + if (!famId || !deviceToken) return c.json({ error: "famId, deviceToken required" }, 400); + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList("members", `famId = '${famId}' && deviceToken = '${hashHex}'`); + const member = members.items?.[0]; + if (!member) return c.json({ error: "Invalid device token" }, 401); + return c.json({ famId: member.famId, memberId: member.id, name: member.name }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/verify-token", async (c) => { + try { + const { deviceToken, famSlug } = await c.req.json(); + if (!deviceToken || !famSlug) return c.json({ error: "deviceToken, famSlug required" }, 400); + const fams = await pb.getList("fams", `slug = '${famSlug}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList("members", `famId = '${fam.id}' && deviceToken = '${hashHex}'`); + const member = members.items?.[0]; + if (!member) return c.json({ error: "Invalid device token" }, 401); + return c.json({ famId: fam.id, memberId: member.id, name: member.name, color: member.color }); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/members/me", requireDeviceToken, async (c) => { + try { + const body = await c.req.json(); + const memberId = c.get("memberId"); + const update: Record = {}; + if (body.name) update.name = body.name; + if (body.color) update.color = body.color; + if (!Object.keys(update).length) return c.json({ error: "Nothing to update" }, 400); + const record = await pb.update("members", memberId, update); + return c.json({ id: record.id, name: record.name, color: record.color }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin CRUD: Chore Templates ────────────────────────── + +app.get("/api/admin/:famId/chore-templates", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const data = await pb.getList("chore_templates", `famId = '${famId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/chore-templates", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + const record = await pb.create("chore_templates", { famId, ...body }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + const body = await c.req.json(); + const record = await pb.update("chore_templates", id, body); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.delete("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + await pb.delete("chore_templates", id); + return c.json({ ok: true }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin CRUD: Members ────────────────────────────────── + +app.get("/api/admin/:famId/members", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const data = await pb.getList("members", `famId = '${famId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/members", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + const record = await pb.create("members", { famId, ...body }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => { + try { + const { famId, id } = c.req.param(); + const body = await c.req.json(); + const record = await pb.update("members", id, body); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.delete("/api/admin/:famId/members/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + await pb.delete("members", id); + return c.json({ ok: true }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin CRUD: Assigned Chores ────────────────────────── + +app.get("/api/admin/:famId/assigned-chores", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const data = await pb.getList("assigned_chores", `famId = '${famId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/assigned-chores", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + const record = await pb.create("assigned_chores", { famId, ...body }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + const body = await c.req.json(); + const record = await pb.update("assigned_chores", id, body); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.delete("/api/admin/:famId/assigned-chores/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + await pb.delete("assigned_chores", id); + return c.json({ ok: true }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Regenerate invite code ──────────────────────── + +app.get("/api/admin/:famId/fam", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const fams = await pb.getList("fams", `id = '${famId}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + return c.json({ name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode, payday: fam.payday }); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + if (body.name !== undefined) { + const name = body.name; + if (!name) return c.json({ error: "name required" }, 400); + const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); + const record = await pb.update("fams", famId, { name, slug }); + return c.json({ name: record.name, slug: record.slug, payday: record.payday }); + } + if (body.payday !== undefined) { + const payday = Number(body.payday); + if (payday < 0 || payday > 6 || !Number.isInteger(payday)) + return c.json({ error: "payday must be 0-6" }, 400); + const record = await pb.update("fams", famId, { payday }); + return c.json({ payday: record.payday }); + } + return c.json({ error: "no valid fields" }, 400); + } catch (err) { return handleError(c, err); } +}); + +app.get("/api/admin/:famId/verify", requireAdmin, async (c) => { + return c.json({ verified: true }); +}); + +app.post("/api/admin/:famId/regen-invite", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + const fams = await pb.getList("fams", `id = '${famId}'`); + const fam = fams.items?.[0]; + if (!fam) return c.json({ error: "Fam not found" }, 404); + await pb.update("fams", famId, { inviteCode }); + return c.json({ inviteCode }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Weekly Summary ────────────────────────────── + +app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const payday = await getFamPayday(famId); + const ws = weekStart(payday); + const [members, assigned, completions] = await Promise.all([ + pb.getList("members", `famId = '${famId}'`), + pb.getList("assigned_chores", `famId = '${famId}'`), + pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`), + ]); + + let rewardPointsList: any[] = []; + try { + const pointRewards = await pb.getList("rewards", `famId = '${famId}' && rewardType = 'points' && claimed = true`); + rewardPointsList = pointRewards.items; + } catch {} // schema may not have rewardType/claimed yet + + const assignedList = assigned.items; + const completionsList = completions.items; + + const daysInWeek: string[] = []; + { + const d = new Date(ws + "T00:00:00Z"); + for (let i = 0; i < 7; i++) { + daysInWeek.push(d.toISOString().slice(0, 10)); + d.setDate(d.getDate() + 1); + } + } + + const summaries = members.items.map((m: any) => { + const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id); + const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id); + + const weekPoints = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "points" ? Number(chore.value) : 0); + }, 0); + + const dayPoints: Record = {}; + const dayCompletions: Record = {}; + for (const day of daysInWeek) { + dayPoints[day] = 0; + dayCompletions[day] = 0; + } + for (const c of memberCompletions) { + const day = (c.date || "").slice(0, 10); + if (dayPoints[day] !== undefined) { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + dayPoints[day] += chore?.type === "points" ? Number(chore.value) : 0; + dayCompletions[day]++; + } + } + + const bonusPoints = rewardPointsList + .filter((r: any) => r.memberId === m.id) + .reduce((sum: number, r: any) => sum + Number(r.value), 0); + + const weekMoney = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "money" ? Number(chore.value) : 0); + }, 0); + + return { + memberId: m.id, + memberName: m.name, + memberColor: m.color, + pointsEarned: weekPoints + bonusPoints, + moneyEarned: weekMoney, + choresCompleted: memberCompletions.length, + totalChores: memberAssignments.length, + dayPoints, + dayCompletions, + }; + }); + + return c.json({ weekStart: ws, daysInWeek, summaries }); + + return c.json({ weekStart: ws, summaries }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin CRUD: Bonus Configs ─────────────────────────── + +app.get("/api/admin/:famId/bonus-configs", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const data = await pb.getList("bonus_configs", `famId = '${famId}'`); + return c.json(data.items); + } catch (err) { + // collection may not exist yet — return empty + return c.json([]); + } +}); + +app.post("/api/admin/:famId/bonus-configs", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + const record = await pb.create("bonus_configs", { famId, ...body, phase: body.phase || "ready", status: "active" }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/admin/:famId/bonus-configs/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + const body = await c.req.json(); + const record = await pb.update("bonus_configs", id, body); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.delete("/api/admin/:famId/bonus-configs/:id", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + await pb.update("bonus_configs", id, { status: "archived" }); + return c.json({ ok: true }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/bonus-configs/:id/destroy", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + await pb.delete("bonus_configs", id); + return c.json({ ok: true }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/bonus-configs/:id/assign", requireAdmin, async (c) => { + try { + const { famId, id } = c.req.param(); + const body = await c.req.json(); + const updates: Record = { phase: "active" }; + if (body.target) updates.target = body.target; + if (body.memberId !== undefined) updates.memberId = body.memberId || null; + const record = await pb.update("bonus_configs", id, updates); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/bonus-configs/:id/complete", requireAdmin, async (c) => { + try { + const { famId, id } = c.req.param(); + const body = await c.req.json(); + const phase = body.phase || "completed"; + const record = await pb.update("bonus_configs", id, { phase, completedAt: phase === "completed" ? new Date().toISOString() : null }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Bonus Config Progress ──────────────────────── + +app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const payday = await getFamPayday(famId); + let configsData: any = { items: [] }; + try { + configsData = await pb.getList("bonus_configs", `famId = '${famId}' && phase = 'active'`); + } catch {} + const [members, assigned, completions] = await Promise.all([ + pb.getList("members", `famId = '${famId}'`), + pb.getList("assigned_chores", `famId = '${famId}'`), + pb.getList("completions", `famId = '${famId}'`), + ]); + + const assignedList = assigned.items; + const completionsList = completions.items; + let allRewards: any = { items: [] }; + try { + allRewards = await pb.getList("rewards", `famId = '${famId}'`); + } catch {} // schema may not have new fields + + const result: any[] = []; + + for (const cfg of configsData.items) { + const cfgRewards = allRewards.items.filter((r: any) => r.bonusConfigId === cfg.id); + const pStart = cfg.period ? periodStart(cfg.period, payday) : ""; + const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ""; + const periodCompletions = cfg.period + ? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd) + : completionsList; + + const progress: any[] = []; + + if (cfg.target === "collaborative") { + const teamCompletions = periodCompletions.filter((c: any) => + members.items.some((m: any) => m.id === c.memberId) + ); + let teamCurrent = 0; + if (cfg.type === "threshold") { + teamCurrent = teamCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "points" ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === "count") { + teamCurrent = teamCompletions.length; + } + const teamReward = cfgRewards[0]; + progress.push({ + memberId: "__team__", + memberName: "Team Total", + memberColor: "#8b5cf6", + current: teamCurrent, + criteriaValue: cfg.criteriaValue || 0, + reward: teamReward + ? { id: teamReward.id, claimed: teamReward.claimed } + : null, + state: teamReward + ? (teamReward.claimed ? "claimed" : "unclaimed") + : "pending", + achieved: teamReward ? true : false, + }); + } + + if (cfg.target !== "collaborative") { + const progressMembers = cfg.target === "individual" && cfg.memberId + ? members.items.filter((m: any) => m.id === cfg.memberId) + : members.items; + for (const m of progressMembers) { + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + const memberReward = cfgRewards.find((r: any) => r.memberId === m.id); + + let current = 0; + if (cfg.type === "threshold") { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "points" ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === "count") { + current = memberCompletions.length; + } else if (cfg.type === "manual") { + current = 0; + } + + progress.push({ + memberId: m.id, + memberName: m.name, + memberColor: m.color, + current, + criteriaValue: cfg.criteriaValue || 0, + reward: memberReward + ? { id: memberReward.id, claimed: memberReward.claimed } + : null, + state: memberReward + ? (memberReward.claimed ? "claimed" : "unclaimed") + : "pending", + achieved: memberReward ? true : false, + }); + } + } + + result.push({ + config: cfg, + progress, + periodStart: pStart, + periodEnd: pEnd, + }); + } + + return c.json(result); + } catch (err) { return handleError(c, err); } +}); + +// ── Auto-evaluate bonus configs after completions ────── + +async function evaluateFam(famId: string): Promise { + let configs: any[] = []; + try { + configs = (await pb.getList("bonus_configs", `famId = '${famId}' && phase = 'active' && type != 'manual'`)).items; + } catch { return; } + if (!configs.length) return; + + const [allMembers, allAssigned, allCompletions] = await Promise.all([ + pb.getList("members", `famId = '${famId}'`), + pb.getList("assigned_chores", `famId = '${famId}'`), + pb.getList("completions", `famId = '${famId}'`), + ]); + let allRewards: any = { items: [] }; + try { allRewards = await pb.getList("rewards", `famId = '${famId}'`); } catch {}; + const paydayEval = await getFamPayday(famId); + + for (const cfg of configs) { + const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval) : ""; + const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : ""; + const periodCompletions = cfg.period + ? allCompletions.items.filter((c: any) => (c.date || "").slice(0, 10) >= pStart2 && (c.date || "").slice(0, 10) <= pEnd) + : allCompletions.items; + + const existingRewards = allRewards.items.filter((r: any) => r.bonusConfigId === cfg.id); + + if (cfg.target === "individual") { + const targetMembers = cfg.memberId + ? allMembers.items.filter((m: any) => m.id === cfg.memberId) + : allMembers.items; + for (const m of targetMembers) { + if (existingRewards.find((r: any) => r.memberId === m.id)) continue; + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + let achieved = false; + if (cfg.type === "threshold") { + const total = memberCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.items.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "points" ? Number(chore.value) : 0); + }, 0); + achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue); + } else if (cfg.type === "count") { + achieved = cfg.criteriaValue > 0 && memberCompletions.length >= Number(cfg.criteriaValue); + } + if (achieved) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).toFixed(2)}` + : `${cfg.name} – ${cfg.rewardValue}`; + const now = new Date().toISOString(); + await pb.create("rewards", { + famId, memberId: m.id, bonusConfigId: cfg.id, + label, value: Number(cfg.rewardValue) || 0, + rewardType: cfg.rewardType, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + } + } + } else if (cfg.target === "collaborative") { + if (existingRewards.length > 0) continue; + const allMemberIds = allMembers.items.map((m: any) => m.id); + const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); + let total = 0; + if (cfg.type === "threshold") { + total = teamCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.items.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "points" ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === "count") { + total = teamCompletions.length; + } + if (cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue)) { + for (const m of allMembers.items) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).toFixed(2)}` + : `${cfg.name} – ${cfg.rewardValue}`; + const now = new Date().toISOString(); + await pb.create("rewards", { + famId, memberId: m.id, bonusConfigId: cfg.id, + label, value: Number(cfg.rewardValue) || 0, + rewardType: cfg.rewardType, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + } + } + } else if (cfg.target === "competitive") { + const scored = allMembers.items.map((m: any) => { + const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); + let current = 0; + if (cfg.type === "threshold") { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.items.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === "points" ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === "count") { + current = memberCompletions.length; + } + return { memberId: m.id, name: m.name, current }; + }); + const qualified = scored.filter((s) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)); + const eligible = qualified.length > 0 ? qualified : scored.filter((s) => s.current > 0); + const winner = eligible.sort((a, b) => b.current - a.current)[0]; + if (winner && !existingRewards.length) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).toFixed(2)}` + : `${cfg.name} – ${cfg.rewardValue}`; + const now = new Date().toISOString(); + await pb.create("rewards", { + famId, memberId: winner.memberId, bonusConfigId: cfg.id, + label, value: Number(cfg.rewardValue) || 0, + rewardType: cfg.rewardType, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + } + } + } +} + +// ── Admin: Bonus Config Tally ────────────────────────── + +app.get("/api/admin/:famId/bonus-configs/tallies", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const payday = await getFamPayday(famId); + const configs = await pb.getList("bonus_configs", `famId = '${famId}'`); + const rewards = await pb.getList("rewards", `famId = '${famId}'`); + + const result: Record = {}; + for (const cfg of configs.items) { + const cfgRewards = rewards.items.filter((r: any) => r.bonusConfigId === cfg.id); + let allTime = cfgRewards.length; + let thisPeriod = allTime; + if (cfg.period) { + const pStart = periodStart(cfg.period, payday); + const pEnd = periodEnd(cfg.period, pStart); + thisPeriod = cfgRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd).length; + } + result[cfg.id] = { thisPeriod, allTime }; + } + return c.json(result); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Manual Trigger ─────────────────────────────── + +app.post("/api/admin/:famId/bonus-configs/:id/trigger", requireAdmin, async (c) => { + try { + const { famId, id } = c.req.param(); + const body = await c.req.json(); + const configs = await pb.getList("bonus_configs", `famId = '${famId}' && id = '${id}' && phase = 'active'`); + const cfg = configs.items?.[0]; + if (!cfg) return c.json({ error: "Bonus config not found" }, 404); + if (cfg.type !== "manual") return c.json({ error: "Only manual-type configs can be triggered" }, 400); + + const existingRewards = await pb.getList("rewards", `famId = '${famId}' && bonusConfigId = '${cfg.id}'`); + + const members = await pb.getList("members", `famId = '${famId}'`); + + const targetMembers: any[] = []; + if (cfg.target === "competitive" || cfg.target === "collaborative") { + for (const m of members.items) targetMembers.push(m); + } else if (cfg.target === "individual") { + const targetId = cfg.memberId || body.memberId; + if (!targetId) return c.json({ error: "memberId required for individual trigger" }, 400); + const member = members.items.find((m: any) => m.id === targetId); + if (!member) return c.json({ error: "Member not found" }, 404); + targetMembers.push(member); + } + + // Check occurrence limits per target member + for (const m of targetMembers) { + const memberRewards = existingRewards.items.filter((r: any) => r.memberId === m.id); + if (cfg.occurrence === "once" && memberRewards.some((r: any) => !r.claimed)) { + return c.json({ error: `Already issued and pending for ${m.name}` }, 400); + } + if (cfg.occurrence === "recurring" && cfg.period) { + const payday = await getFamPayday(famId); + const pStart = periodStart(cfg.period, payday); + const pEnd = periodEnd(cfg.period, pStart); + const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd); + if (periodRewards.length > 0) { + return c.json({ error: `Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}` }, 400); + } + } + } + + const created: any[] = []; + for (const m of targetMembers) { + const label = cfg.rewardType === "cash" + ? `${cfg.name} – £${(Number(cfg.rewardValue) / 100).toFixed(2)}` + : `${cfg.name} – ${cfg.rewardValue}`; + const now = new Date().toISOString(); + const record = await pb.create("rewards", { + famId, memberId: m.id, bonusConfigId: cfg.id, + label, value: Number(cfg.rewardValue) || 0, + rewardType: cfg.rewardType, + claimed: cfg.rewardType === "points", + claimedAt: cfg.rewardType === "points" ? now : null, + date: now.slice(0, 10), + }); + created.push(record); + } + + return c.json({ triggered: true, created: created.length, records: created }); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Completion Toggle ──────────────────────────── + +app.post("/api/completions/toggle", requireDeviceToken, async (c) => { + try { + const famId = c.get("famId"); + const memberId = c.get("memberId"); + const { assignedChoreId, date } = await c.req.json(); + if (!assignedChoreId || !date) { + return c.json({ error: "assignedChoreId and date required" }, 400); + } + const nextDay = new Date(new Date(date + "T00:00:00Z").getTime() + 86400000).toISOString().slice(0, 10); + const existing = await pb.getList( + "completions", + `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${date}' && date < '${nextDay}'`, + ); + if (existing.items?.length > 0) { + await pb.delete("completions", existing.items[0].id); + evaluateFam(famId).catch(() => {}); + return c.json({ completed: false }); + } + const record = await pb.create("completions", { famId, memberId, assignedChoreId, date }); + evaluateFam(famId).catch(() => {}); + return c.json({ completed: true, record }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Evaluate Bonus Configs (manual trigger) ────── + +app.post("/api/admin/:famId/bonus-configs/evaluate", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + await evaluateFam(famId); + return c.json({ evaluated: true }); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Get chores (for kanban) ────────────────────── + +app.post("/api/members/my-chores", requireDeviceToken, async (c) => { + try { + const famId = c.get("famId"); + const memberId = c.get("memberId"); + const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ + pb.getList("chore_templates", `famId = '${famId}'`), + pb.getList("assigned_chores", `famId = '${famId}' && memberId = '${memberId}'`), + pb.getList("completions", `famId = '${famId}' && memberId = '${memberId}'`), + pb.getList("rewards", `famId = '${famId}' && memberId = '${memberId}'`), + pb.getList("bonus_configs", `famId = '${famId}' && phase = 'active'`), + ]); + const payday = await getFamPayday(famId); + const rewardsList = rewards.items; + const configsList = bonusConfigs.items; + return c.json({ + templates: templates.items, + assigned: assigned.items, + completions: completions.items, + rewards: rewardsList, + bonusConfigs: configsList, + }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: View member kanban data ───────────────────── + +app.get("/api/admin/:famId/members/:memberId/chores", requireAdmin, async (c) => { + try { + const { famId, memberId } = c.req.param(); + const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ + pb.getList("chore_templates", `famId = '${famId}'`), + pb.getList("assigned_chores", `famId = '${famId}' && memberId = '${memberId}'`), + pb.getList("completions", `famId = '${famId}' && memberId = '${memberId}'`), + pb.getList("rewards", `famId = '${famId}' && memberId = '${memberId}'`), + pb.getList("bonus_configs", `famId = '${famId}' && phase = 'active'`), + ]); + const payday = await getFamPayday(famId); + const rewardsList = rewards.items; + const configsList = bonusConfigs.items; + return c.json({ + templates: templates.items, + assigned: assigned.items, + completions: completions.items, + rewards: rewardsList, + bonusConfigs: configsList, + }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Rewards ───────────────────────────────────── + +app.get("/api/admin/:famId/completions", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const data = await pb.getList("completions", `famId = '${famId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.get("/api/admin/:famId/rewards", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const data = await pb.getList("rewards", `famId = '${famId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/rewards/:id/claim", requireAdmin, async (c) => { + try { + const { id } = c.req.param(); + const now = new Date().toISOString(); + const record = await pb.update("rewards", id, { claimed: true, claimedAt: now }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Revoke a completion ──────────────────────── + +app.post("/api/admin/:famId/completions/:id/revoke", requireAdmin, async (c) => { + try { + const { famId, id } = c.req.param(); + const completion = await pb.getList("completions", `famId = '${famId}' && id = '${id}'`); + if (!completion.items?.length) return c.json({ error: "Completion not found" }, 404); + await pb.delete("completions", id); + evaluateFam(famId).catch(() => {}); + return c.json({ revoked: true }); + } catch (err) { return handleError(c, err); } +}); + +// ── Admin: Send notification message ──────────────────── + +app.get("/api/admin/:famId/profile", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const userId = c.req.header("x-session-userid"); + const admins = await pb.getList("fam_admins", `famId = '${famId}' && userId = '${userId}'`); + const admin = admins.items?.[0]; + if (!admin) return c.json({ error: "Admin not found" }, 404); + return c.json({ id: admin.id, name: admin.name, color: admin.color, email: admin.email || "" }); + } catch (err) { return handleError(c, err); } +}); + +app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const userId = c.req.header("x-session-userid"); + const body = await c.req.json(); + const admins = await pb.getList("fam_admins", `famId = '${famId}' && userId = '${userId}'`); + const admin = admins.items?.[0]; + if (!admin) return c.json({ error: "Admin not found" }, 404); + const update: Record = {}; + if (body.name) update.name = body.name; + if (body.color) update.color = body.color; + if (body.email !== undefined) update.email = body.email; + const record = await pb.update("fam_admins", admin.id, update); + return c.json({ id: record.id, name: record.name, color: record.color, email: record.email || "" }); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/admin/:famId/send-message", requireAdmin, async (c) => { + try { + const { famId } = c.req.param(); + const body = await c.req.json(); + if (!body.memberId || !body.message) { + return c.json({ error: "memberId and message required" }, 400); + } + const record = await pb.create("notifications", { + famId, + memberId: body.memberId, + message: body.message, + read: false, + }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Claim a reward ───────────────────────────── + +app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => { + try { + const { id } = c.req.param(); + const famId = c.get("famId"); + const memberId = c.get("memberId"); + const now = new Date().toISOString(); + const record = await pb.update("rewards", id, { claimed: true, claimedAt: now }); + // Create notification for admin + const members = await pb.getList("members", `famId = '${famId}' && id = '${memberId}'`); + const memberName = members.items?.[0]?.name || "Unknown"; + const msg = `Claim made by ${memberName}`; + await pb.create("notifications", { famId, memberId, message: msg, read: false }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Member: Notifications ────────────────────────────── + +app.get("/api/members/notifications", requireDeviceToken, async (c) => { + try { + const famId = c.get("famId"); + const memberId = c.get("memberId"); + const data = await pb.getList("notifications", `famId = '${famId}' && memberId = '${memberId}'`); + return c.json(data.items); + } catch (err) { return handleError(c, err); } +}); + +app.post("/api/members/notifications/:id/dismiss", requireDeviceToken, async (c) => { + try { + const { id } = c.req.param(); + const record = await pb.update("notifications", id, { read: true }); + return c.json(record); + } catch (err) { return handleError(c, err); } +}); + +// ── Start server ───────────────────────────────────────── + const port = parseInt(process.env.PROXY_PORT || "3456", 10); +const SERVER_IP = "192.168.1.225"; -serve({ fetch: app.fetch, port }, (info) => { - console.log(`Hono proxy listening on ${SERVER_IP}:${info.port}`); +serve({ fetch: app.fetch, port }, async (info) => { + console.log(`Hono proxy listening on ${SERVER_IP}:${info.port}`); + try { + await migrate(); + } catch (e) { + console.error("[migrate] Failed:", e); + } }); diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts new file mode 100644 index 0000000..b1808f3 --- /dev/null +++ b/proxy/src/migrate.ts @@ -0,0 +1,557 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +let token: string | null = null; + +async function auth(): Promise { + if (token) return token; + const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); + token = data.token; + return token!; +} + +async function getCollection(name: string): Promise { + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + return data?.items?.[0] || null; +} + +async function createCollection(col: any): Promise { + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify(col), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); + console.log(` ✓ Created collection: ${col.name}`); +} + +async function updateCollection(id: string, col: any): Promise { + const t = await auth(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify(col), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); + console.log(` ✓ Updated collection: ${col.name || id}`); +} + +export async function migrate(): Promise { + console.log("[migrate] Checking PB collection schemas..."); + + // ── 1. Create bonus_configs if missing ── + const existing = await getCollection("bonus_configs"); + if (!existing) { + // Need to get fams collection ID first + const famsCol = await getCollection("fams"); + if (!famsCol) throw new Error("fams collection not found"); + const famsId = famsCol.id; + + console.log("[migrate] Creating bonus_configs collection..."); + await createCollection({ + name: "bonus_configs", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, + { name: "name", type: "text", required: true }, + { name: "description", type: "text", required: false }, + { name: "target", type: "select", required: true, values: ["individual", "competitive", "collaborative"], maxSelect: 1 }, + { name: "type", type: "select", required: true, values: ["threshold", "count", "manual"], maxSelect: 1 }, + { name: "occurrence", type: "select", required: true, values: ["recurring", "once"], maxSelect: 1 }, + { name: "rewardType", type: "select", required: true, values: ["points", "cash", "prize"], maxSelect: 1 }, + { name: "rewardValue", type: "text", required: true }, + { name: "criteriaValue", type: "number", required: false }, + { name: "period", type: "select", required: false, values: ["schedule", "weekly", "monthly"], maxSelect: 1 }, + { name: "status", type: "select", required: true, values: ["active", "archived"], maxSelect: 1 }, + ], + }); + } else { + console.log(` ↳ bonus_configs already exists`); + + const targetField = existing.fields.find((f: any) => f.name === "target"); + const periodField = existing.fields.find((f: any) => f.name === "period"); + const needTargetUpdate = targetField && !targetField.values.includes("collaborative"); + const needPeriodUpdate = periodField && !periodField.values.includes("schedule"); + + if (needTargetUpdate || needPeriodUpdate) { + console.log("[migrate] Updating bonus_configs fields..."); + if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"]; + if (needPeriodUpdate) periodField.values = ["schedule", "weekly", "monthly"]; + await updateCollection(existing.id, { + name: "bonus_configs", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: existing.fields, + }); + } + } + + // ── 2. Update rewards collection ── + const rewardsCol = await getCollection("rewards"); + if (rewardsCol) { + const fieldNames = rewardsCol.fields.map((f: any) => f.name); + const needsUpdate = + !fieldNames.includes("date") || + fieldNames.includes("autoClaimed") || + fieldNames.includes("weekStart") || + fieldNames.includes("month"); + + if (needsUpdate) { + console.log("[migrate] Updating rewards collection schema..."); + + const bonusConfigsCol = await getCollection("bonus_configs"); + + const keepFields = rewardsCol.fields.filter((f: any) => + ["famId", "memberId", "label", "value", "claimed", "claimedAt", "rewardType", "bonusConfigId", "created", "updated", "id"].includes(f.name) + ); + + const newFields = [ + ...keepFields, + ...(bonusConfigsCol && !fieldNames.includes("bonusConfigId") + ? [{ name: "bonusConfigId", type: "relation", required: false, collectionId: bonusConfigsCol.id, maxSelect: 1, cascadeDelete: false }] + : []), + ...(fieldNames.includes("rewardType") ? [] : [{ name: "rewardType", type: "select", required: true, values: ["cash", "prize", "points"], maxSelect: 1 }]), + ...(fieldNames.includes("claimedAt") ? [] : [{ name: "claimedAt", type: "date", required: false }]), + { name: "date", type: "text", required: false }, + ]; + + await updateCollection(rewardsCol.id, { + name: "rewards", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: newFields, + }); + + // Backfill existing rewards + const today = new Date().toISOString().slice(0, 10); + const backfillRes = await fetch( + `${PB_ENDPOINT}/api/collections/rewards/records?perPage=200`, + { headers: { Authorization: `Bearer ${await auth()}` } }, + ); + const backfillData = await backfillRes.json(); + if (backfillData?.items) { + for (const r of backfillData.items) { + const t2 = await auth(); + const patches: Record = {}; + if (!r.date) { + patches.date = r.claimedAt?.slice(0, 10) || today; + } + if (r.rewardType === "points" && !r.claimed) { + patches.claimed = true; + patches.claimedAt = new Date().toISOString(); + } + if (Object.keys(patches).length > 0) { + await fetch(`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t2}` }, + body: JSON.stringify(patches), + }); + } + } + console.log(` ✓ Backfilled ${backfillData.items.length} rewards`); + } + } else { + console.log(` ↳ rewards schema is current`); + } + } else { + console.log(` ↳ rewards collection not found (will be created by seed)`); + } + + // ── 3. Update settings collection (drop old fields) ── + const settingsCol = await getCollection("settings"); + if (settingsCol) { + const fieldNames = settingsCol.fields.map((f: any) => f.name); + if (fieldNames.includes("pointsThreshold") || fieldNames.includes("weeklyBonus")) { + console.log("[migrate] Updating settings collection (dropping old fields)..."); + const keepFields = settingsCol.fields.filter((f: any) => + ["famId", "webhookUrl", "created", "updated", "id"].includes(f.name) + ); + await updateCollection(settingsCol.id, { + name: "settings", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: keepFields, + }); + } else { + console.log(` ↳ settings schema is current`); + } + } + + // ── 4. Create notifications collection if missing ── + const notifCol = await getCollection("notifications"); + if (!notifCol) { + const famsCol = await getCollection("fams"); + if (!famsCol) throw new Error("fams collection not found"); + const membersCol = await getCollection("members"); + if (!membersCol) throw new Error("members collection not found"); + console.log("[migrate] Creating notifications collection..."); + await createCollection({ + name: "notifications", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: "famId", type: "relation", required: true, collectionId: famsCol.id, maxSelect: 1, cascadeDelete: false }, + { name: "memberId", type: "relation", required: true, collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false }, + { name: "message", type: "text", required: true }, + { name: "read", type: "bool", required: false }, + ], + }); + } else { + console.log(` ↳ notifications already exists`); + } + + // ── 5. Add payday field to fams if missing ── + const famsCol = await getCollection("fams"); + if (famsCol) { + const hasPayday = famsCol.fields.some((f: any) => f.name === "payday"); + if (!hasPayday) { + console.log("[migrate] Adding payday field to fams..."); + const paydayField = { name: "payday", type: "number", required: false, min: 0, max: 6 }; + famsCol.fields.push(paydayField); + await updateCollection(famsCol.id, { + name: "fams", + type: "base", + listRule: famsCol.listRule, + viewRule: famsCol.viewRule, + createRule: famsCol.createRule, + updateRule: famsCol.updateRule, + deleteRule: famsCol.deleteRule, + fields: famsCol.fields, + }); + } else { + console.log(` ↳ fams.payday already exists`); + } + } + + // ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ── + // PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date` + // and just normalise existing records. The frontend reads with .slice(0, 10) either way. + try { + const completionsCol = await getCollection("completions"); + if (completionsCol) { + const t = await auth(); + const all = await fetch(`${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await all.json(); + if (data?.items?.length) { + let backfilled = 0; + for (const rec of data.items) { + const plain = rec.date?.slice(0, 10); + if (plain && plain !== rec.date) { + await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ date: plain }), + }); + backfilled++; + } + } + if (backfilled > 0) console.log(` ✓ Backfilled ${backfilled} completion dates`); + else console.log(` ↳ completions.date already normalised`); + } + } + } catch (e) { + console.log(` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`); + } + + // ── 7. Add memberId field to bonus_configs ── + const bonusConfigsCol = await getCollection("bonus_configs"); + if (bonusConfigsCol) { + const hasMemberId = bonusConfigsCol.fields.some((f: any) => f.name === "memberId"); + if (!hasMemberId) { + const membersCol = await getCollection("members"); + if (membersCol) { + console.log("[migrate] Adding memberId field to bonus_configs..."); + bonusConfigsCol.fields.push({ + name: "memberId", type: "relation", required: false, + collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false, + }); + await updateCollection(bonusConfigsCol.id, { + name: "bonus_configs", + type: "base", + listRule: bonusConfigsCol.listRule, + viewRule: bonusConfigsCol.viewRule, + createRule: bonusConfigsCol.createRule, + updateRule: bonusConfigsCol.updateRule, + deleteRule: bonusConfigsCol.deleteRule, + fields: bonusConfigsCol.fields, + }); + } + } else { + console.log(` ↳ bonus_configs.memberId already exists`); + } + } + + // ── 8. Add phase field to bonus_configs ── + if (bonusConfigsCol) { + const hasPhase = bonusConfigsCol.fields.some((f: any) => f.name === "phase"); + if (!hasPhase) { + console.log("[migrate] Adding phase field to bonus_configs..."); + bonusConfigsCol.fields.push({ + name: "phase", type: "select", required: true, + values: ["template", "ready", "active", "completed"], maxSelect: 1, + }); + await updateCollection(bonusConfigsCol.id, { + name: "bonus_configs", + type: "base", + listRule: bonusConfigsCol.listRule, + viewRule: bonusConfigsCol.viewRule, + createRule: bonusConfigsCol.createRule, + updateRule: bonusConfigsCol.updateRule, + deleteRule: bonusConfigsCol.deleteRule, + fields: bonusConfigsCol.fields, + }); + + // Backfill existing records → phase = "active" + const t = await auth(); + const all = await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await all.json(); + if (data?.items?.length) { + for (const rec of data.items) { + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ phase: "active" }), + }); + } + console.log(` ✓ Backfilled ${data.items.length} bonus_configs → phase=active`); + } + } else { + console.log(` ↳ bonus_configs.phase already exists`); + } + } + + // ── 9. Add role + userId fields to members ── + const membersCol = await getCollection("members"); + if (membersCol) { + const hasRole = membersCol.fields.some((f: any) => f.name === "role"); + const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); + if (!hasRole || !hasUserId) { + console.log("[migrate] Adding role/userId fields to members..."); + if (!hasRole) membersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 }); + if (!hasUserId) membersCol.fields.push({ name: "userId", type: "text", required: false }); + await updateCollection(membersCol.id, { + name: "members", + type: "base", + listRule: membersCol.listRule, + viewRule: membersCol.viewRule, + createRule: membersCol.createRule, + updateRule: membersCol.updateRule, + deleteRule: membersCol.deleteRule, + fields: membersCol.fields, + }); + } else { + console.log(` ↳ members.role/userId already exists`); + } + } + + // ── 10. Add email field to members ── + if (membersCol) { + const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); + if (!hasEmail) { + console.log("[migrate] Adding email field to members..."); + membersCol.fields.push({ name: "email", type: "text", required: false }); + await updateCollection(membersCol.id, { + name: "members", + type: "base", + listRule: membersCol.listRule, + viewRule: membersCol.viewRule, + createRule: membersCol.createRule, + updateRule: membersCol.updateRule, + deleteRule: membersCol.deleteRule, + fields: membersCol.fields, + }); + } else { + console.log(` ↳ members.email already exists`); + } + } + + // ── 11. Backfill userId on existing member records ── + if (membersCol) { + try { + const t = await auth(); + const allMembers = await fetch(`${PB_ENDPOINT}/api/collections/members/records?perPage=1000`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const membersData = await allMembers.json(); + const needBackfill = (membersData?.items || []).filter((m: any) => !m.userId); + if (needBackfill.length > 0) { + console.log(`[migrate] Backfilling userId for ${needBackfill.length} members...`); + const adminsRes = await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const adminsData = await adminsRes.json(); + const adminsByFam = new Map(); + for (const a of (adminsData?.items || [])) { + const list = adminsByFam.get(a.famId) || []; + list.push(a); + adminsByFam.set(a.famId, list); + } + let count = 0; + for (const m of needBackfill) { + const admins = adminsByFam.get(m.famId) || []; + if (admins.length === 1) { + await fetch(`${PB_ENDPOINT}/api/collections/members/records/${m.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ userId: admins[0].userId }), + }); + count++; + } + } + if (count > 0) console.log(` ✓ Backfilled ${count} member userIds`); + if (count < needBackfill.length) console.log(` ↳ Skipped ${needBackfill.length - count} members (no unique fam_admin match)`); + } else { + console.log(` ↳ members.userId already backfilled`); + } + } catch (e) { + console.log(` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`); + } + } + + // ── 12. Drop userId field from members ── + if (membersCol) { + const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); + if (hasUserId) { + console.log("[migrate] Dropping userId field from members..."); + membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "userId"); + await updateCollection(membersCol.id, { + name: "members", + type: "base", + listRule: membersCol.listRule, + viewRule: membersCol.viewRule, + createRule: membersCol.createRule, + updateRule: membersCol.updateRule, + deleteRule: membersCol.deleteRule, + fields: membersCol.fields, + }); + } else { + console.log(` ↳ members.userId already removed`); + } + } + + // ── 13. Add name + color fields to fam_admins ── + const adminsCol = await getCollection("fam_admins"); + if (adminsCol) { + const hasName = adminsCol.fields.some((f: any) => f.name === "name"); + const hasColor = adminsCol.fields.some((f: any) => f.name === "color"); + if (!hasName || !hasColor) { + console.log("[migrate] Adding name/color to fam_admins..."); + if (!hasName) adminsCol.fields.push({ name: "name", type: "text", required: false }); + if (!hasColor) adminsCol.fields.push({ name: "color", type: "text", required: false }); + await updateCollection(adminsCol.id, { + name: "fam_admins", + type: "base", + listRule: adminsCol.listRule || "", + viewRule: adminsCol.viewRule || "", + createRule: adminsCol.createRule, + updateRule: adminsCol.updateRule, + deleteRule: adminsCol.deleteRule, + fields: adminsCol.fields, + }); + // Backfill name from email prefix + const t = await auth(); + const all = await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await all.json(); + for (const a of (data?.items || [])) { + const patch: Record = {}; + if (!a.name) patch.name = (a.email || "admin").split("@")[0]; + if (!a.color) patch.color = "#6366f1"; + if (Object.keys(patch).length) { + await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify(patch), + }); + } + } + console.log(` ✓ Backfilled name/color for ${(data?.items || []).length} admins`); + } else { + console.log(` ↳ fam_admins.name/color already exists`); + } + } + + // ── 14. Drop role field from members ── + if (membersCol) { + const hasRole = membersCol.fields.some((f: any) => f.name === "role"); + if (hasRole) { + console.log("[migrate] Dropping role field from members..."); + membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "role"); + await updateCollection(membersCol.id, { + name: "members", + type: "base", + listRule: membersCol.listRule, + viewRule: membersCol.viewRule, + createRule: membersCol.createRule, + updateRule: membersCol.updateRule, + deleteRule: membersCol.deleteRule, + fields: membersCol.fields, + }); + } else { + console.log(` ↳ members.role already removed`); + } + } + + // ── 15. Drop email field from members ── + if (membersCol) { + const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); + if (hasEmail) { + console.log("[migrate] Dropping email field from members..."); + membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "email"); + await updateCollection(membersCol.id, { + name: "members", + type: "base", + listRule: membersCol.listRule, + viewRule: membersCol.viewRule, + createRule: membersCol.createRule, + updateRule: membersCol.updateRule, + deleteRule: membersCol.deleteRule, + fields: membersCol.fields, + }); + } else { + console.log(` ↳ members.email already removed`); + } + } + + console.log("[migrate] Done"); +} diff --git a/proxy/src/pb.ts b/proxy/src/pb.ts new file mode 100644 index 0000000..9942ec6 --- /dev/null +++ b/proxy/src/pb.ts @@ -0,0 +1,94 @@ +import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; + +const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; + +let adminToken: string | null = null; +let tokenExpiry = 0; + +async function ensureToken(): Promise { + if (adminToken && Date.now() < tokenExpiry) return adminToken; + const res = await fetch( + `${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), + }, + ); + const data = await res.json(); + if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); + adminToken = data.token; + tokenExpiry = Date.now() + 23 * 60 * 60 * 1000; + return adminToken!; +} + +async function request( + method: string, + path: string, + body?: unknown, +): Promise { + const token = await ensureToken(); + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + if (body) headers["Content-Type"] = "application/json"; + return fetch(`${PB_ENDPOINT}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); +} + +export const pb = { + async create(collection: string, data: Record) { + const res = await request("POST", `/api/collections/${collection}/records`, data); + const json = await res.json(); + if (!res.ok) throw new Error(`PB create ${collection}: ${JSON.stringify(json)}`); + return json; + }, + + async update(collection: string, id: string, data: Record) { + const res = await request("PATCH", `/api/collections/${collection}/records/${id}`, data); + const json = await res.json(); + if (!res.ok) throw new Error(`PB update ${collection}: ${JSON.stringify(json)}`); + return json; + }, + + async delete(collection: string, id: string) { + const res = await request("DELETE", `/api/collections/${collection}/records/${id}`); + if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status}`); + }, + + async getList(collection: string, filter = "") { + const params = new URLSearchParams(); + if (filter) params.set("filter", filter); + params.set("perPage", "200"); + const res = await request("GET", `/api/collections/${collection}/records?${params}`); + const json = await res.json(); + if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`); + return json; + }, + + async authWithPassword(identity: string, password: string) { + const res = await fetch( + `${PB_ENDPOINT}/api/collections/users/auth-with-password`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity, password }), + }, + ); + const json = await res.json(); + if (!res.ok) throw new Error(`PB auth: ${JSON.stringify(json)}`); + return json; + }, + + async createUser(email: string, password: string) { + return pb.create("users", { + email, + password, + passwordConfirm: password, + emailVisibility: false, + }); + }, +};