import { SCHEMA_PLAN } from '@shared/pb/schema'; import { PB_ENDPOINT } from '$lib/server/pocketbase'; import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private'; import { getPlatformFlags } from '$lib/server/platform'; let token: string | null = null; let tokenExpiry = 0; async function auth(): 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 auth failed: ${JSON.stringify(data)}`); token = data.token; // PB JWTs expire in 1 hour; refresh 5 min early. tokenExpiry = Date.now() + 55 * 60 * 1000; return token!; } async function isDemo(): Promise { const flags = await getPlatformFlags(); return flags.demo === true; } 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}`); return data?.id || null; } 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}`); } async function createRecord(collection: string, data: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify(data) }); if (!res.ok) { const d = await res.json(); throw new Error(`Create record ${collection} failed: ${JSON.stringify(d)}`); } } // Seed two default seasons (Term + Holidays) per family so the family-admin // season picker always has options from day one and they can tick either on/off. // Only seeds when a family has zero seasons, so user deletions stick (a deleted // default never comes back on boot as long as at least one season remains). async function ensureDefaultSeasons(): Promise { const famsCol = await getCollection('fams'); if (!famsCol) return; const t = await auth(); const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, { headers: { Authorization: `Bearer ${t}` } }); const fams = (await famsRes.json()).items || []; const defaults = [ { name: 'Term', color: '#6366f1', active: true }, { name: 'Holidays', color: '#f59e0b', active: true } ]; for (const fam of fams) { const sRes = await fetch( `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, { headers: { Authorization: `Bearer ${t}` } } ); const have = ((await sRes.json()).items || []) as any[]; // Only seed defaults for families that have no seasons at all. Seeding // per-name (idempotent) forced a deleted default (e.g. "Term time") back // on every boot; now a user who removes one keeps it removed as long as // at least one season remains. if (have.length === 0) { for (const d of defaults) { await createRecord('seasons', { famId: fam.id, ...d }); console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`); } } } } // Apply the custom fields + rules the app relies on to PB's native `users` // auth collection (created automatically on first serve). Children live here as // role='child'; username is a password-auth identity so the server can // authWithPassword(derivedPassword) at OTP join time. async function ensureUsers(ids: Record): Promise { const usersCol = await getCollection('users'); if (!usersCol) throw new Error('users collection not found'); const famsId = ids.fams || (await getCollection('fams'))?.id; if (!famsId) throw new Error('fams collection not found'); const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); let changed = false; const emailField = usersCol.fields.find((f: any) => f.name === 'email'); if (emailField && emailField.required) { emailField.required = false; changed = true; } if (!has('famId')) { usersCol.fields.push({ name: 'famId', type: 'relation', required: false, collectionId: famsId, maxSelect: 1, cascadeDelete: false }); changed = true; } if (!has('role')) { usersCol.fields.push({ name: 'role', type: 'select', required: false, values: ['parent', 'child'], maxSelect: 1 }); changed = true; } if (!has('username')) { usersCol.fields.push({ name: 'username', type: 'text', required: true }); changed = true; } if (!has('color')) { usersCol.fields.push({ name: 'color', type: 'text', required: false }); changed = true; } if (!has('pattern')) { usersCol.fields.push({ name: 'pattern', type: 'text', required: false }); changed = true; } if (!has('themeSize')) { usersCol.fields.push({ name: 'themeSize', type: 'text', required: false }); changed = true; } if (!has('themeOpacity')) { usersCol.fields.push({ name: 'themeOpacity', type: 'text', required: false }); changed = true; } if (!has('passwordResetToken')) { usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false }); changed = true; } if (!has('passwordResetExpiry')) { usersCol.fields.push({ name: 'passwordResetExpiry', type: 'text', required: false }); changed = true; } let indexes = usersCol.indexes || []; if (!indexes.some((i: string) => /username/i.test(i))) { indexes = [ ...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''" ]; changed = true; } const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ['email'] }; const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ['email']; if (!identityFields.includes('username')) { identityFields.push('username'); changed = true; } const listRule = 'famId = @request.auth.famId'; const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'"; // Members can edit their own record (name/colour); parents can edit any // family member. Delete stays parent-only. const selfOrParentWrite = "@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')"; if ( usersCol.listRule !== listRule || usersCol.viewRule !== listRule || usersCol.updateRule !== selfOrParentWrite || usersCol.deleteRule !== parentWrite ) { changed = true; } if (changed) { await updateCollection(usersCol.id, { name: 'users', type: 'auth', listRule, viewRule: listRule, createRule: usersCol.createRule || '', updateRule: selfOrParentWrite, deleteRule: parentWrite, fields: usersCol.fields, indexes, passwordAuth: { enabled: true, identityFields } }); } } // Superuser-only OTP store for the child join gate. Holds the rotating code and // its issue timestamp (20-min window). Not public — read/written via the // superuser client only. async function ensureOtp(ids: Record): Promise { if (await getCollection('otp')) return; const famsId = ids.fams || (await getCollection('fams'))?.id; const usersId = ids.users || (await getCollection('users'))?.id; if (!famsId || !usersId) throw new Error('fams/users collection not found'); await createCollection({ name: 'otp', type: 'base', listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, fields: [ { name: 'famId', type: 'relation', required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, { name: 'userId', type: 'relation', required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, { name: 'otp', type: 'text', required: false }, { name: 'updatedAt', type: 'text', required: false } ] }); } // Child shared-device PINs. Superuser-only rules (like `otp`) — all access // goes through server endpoints with session-role checks, so children can // never read siblings' pins via PB rules. async function ensurePins(ids: Record): Promise { if (await getCollection('pins')) return; const famsId = ids.fams || (await getCollection('fams'))?.id; const usersId = ids.users || (await getCollection('users'))?.id; if (!famsId || !usersId) throw new Error('fams/users collection not found'); await createCollection({ name: 'pins', type: 'base', listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, fields: [ { name: 'famId', type: 'relation', required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, { name: 'userId', type: 'relation', required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, { name: 'pin', type: 'text', required: false } ] }); } // Idempotent field-add for the shared-device idle lock (0 = off, else mins). async function ensureSettingsFields(): Promise { const settingsCol = await getCollection('settings'); if (!settingsCol) return; const has = (n: string) => settingsCol.fields.some((f: any) => f.name === n); if (has('lockMins')) return; settingsCol.fields.push({ name: 'lockMins', type: 'number', required: false }); await updateCollection(settingsCol.id, { fields: settingsCol.fields }); } // Platform access codes — the codes that enable access to the platform. They're // global (not fam-scoped) and managed via the platform admin page (superuser // only), so all rules are null like `otp`. A code grants a family a subscription // for `duration` months (0 = continuous); `expiry` is months-after-createdAt // (0 = never expires); `active` is a failsafe toggle. Entered at create-family // and in admin settings. async function ensureAccessCodes(): Promise { if (await getCollection('accesscodes')) { await ensureAccessCodeFields(); await seedAccessCodes(); return; } await createCollection({ name: 'accesscodes', type: 'base', listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, fields: [ { name: 'value', type: 'text', required: true, unique: true }, { name: 'name', type: 'text', required: true }, { name: 'duration', type: 'number', required: false }, { name: 'expiry', type: 'number', required: false }, { name: 'active', type: 'bool', required: false }, // When set (>0) this code is a TRIAL code: maps to Stripe // trial_period_days at checkout instead of platform access. { name: 'trialDays', type: 'number', required: false }, { name: 'createdAt', type: 'date', required: false } ] }); await seedAccessCodes(); } // Idempotent field-add for installs where accesscodes predates a field. async function ensureAccessCodeFields(): Promise { const col = await getCollection('accesscodes'); if (!col) return; const has = (n: string) => col.fields.some((f: any) => f.name === n); if (!has('trialDays')) { await updateCollection(col.id, { ...col, fields: [...col.fields, { name: 'trialDays', type: 'number', required: false }] }); } } // Idempotent seeds — developer code + an example trial code. async function seedAccessCodes(): Promise { const t = await auth(); const seeds = [ { value: 'dev123', name: 'developer', duration: 0, expiry: 0, active: true, trialDays: null }, { value: 'FAM3MONTHS', name: '3-month trial', duration: null, expiry: null, active: true, trialDays: 90 } ]; for (const seed of seeds) { const res = await fetch( `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`, { headers: { Authorization: `Bearer ${t}` } } ); const data = await res.json(); if (data?.items?.length) continue; const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }) }); const c = await created.json(); if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`); console.log(` ✓ Seeded access code: ${seed.name} (${seed.value})`); } } // Platform settings — a single global record holding the platform feature // flags (replaces the per-fam fams.featureFlags). Publicly readable (empty // list/view rules) so every client can deduce flags on app load; writes stay // superuser-only (null rules), so like otp/accesscodes this lives in // migrate.ts rather than SCHEMA_PLAN. async function ensurePlatform(): Promise { if (!(await getCollection('platform'))) { await createCollection({ name: 'platform', type: 'base', listRule: '', viewRule: '', createRule: null, updateRule: null, deleteRule: null, fields: [ { name: 'label', type: 'text', required: true }, { name: 'flags', type: 'json', required: false } ] }); } await seedPlatform(); } // Idempotent seed — create the singleton 'global' settings record if missing. async function seedPlatform(): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, { headers: { Authorization: `Bearer ${t}` } }); const data = await res.json(); if (data?.items?.length) return; const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ label: 'global', flags: { debug: false, rewardsTargets: false } }) }); const c = await created.json(); if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); console.log(' ✓ Seeded platform settings (global)'); } // Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if // `fams` already exists (data is disposable; there is no incremental migration // history). async function ensureSchema(): Promise { if (await getCollection('fams')) { console.log('[migrate] Schema already present — skipping bootstrap.'); return; } console.log('[migrate] Bootstrapping schema on fresh PocketBase...'); const ids: Record = {}; const nativeUsers = await getCollection('users'); if (nativeUsers) ids.users = nativeUsers.id; for (const entry of SCHEMA_PLAN) { const createdId = await createCollection(entry.build(ids)); if (createdId) ids[entry.name] = createdId; } await ensureUsers(ids); await ensureOtp(ids); await ensurePins(ids); console.log('[migrate] Schema bootstrapped.'); } // Add theme + newer auth fields to `users` on installs where the collection // already exists (fresh installs get them via ensureUsers). Idempotent — // only adds missing fields. Mirrors ensureFamFields. async function ensureUserFields(): Promise { const usersCol = await getCollection('users'); if (!usersCol) return; const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); const needed: any[] = []; if (!has('pattern')) { needed.push({ name: 'pattern', type: 'text', required: false }); } if (!has('themeSize')) { needed.push({ name: 'themeSize', type: 'text', required: false }); } if (!has('themeOpacity')) { needed.push({ name: 'themeOpacity', type: 'text', required: false }); } if (!has('passwordResetToken')) { needed.push({ name: 'passwordResetToken', type: 'text', required: false }); } if (!has('passwordResetExpiry')) { needed.push({ name: 'passwordResetExpiry', type: 'text', required: false }); } if (!has('partyEmoji')) { needed.push({ name: 'partyEmoji', type: 'text', required: false }); } if (needed.length) { await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] }); } } // Add the access-gating fields to `fams` on installs where it already exists // (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing // fields. async function ensureFamFields(): Promise { const famsCol = await getCollection('fams'); if (!famsCol) return; const has = (n: string) => famsCol.fields.some((f: any) => f.name === n); const needed: any[] = []; if (!has('active')) { needed.push({ name: 'active', type: 'bool', required: false }); } if (!has('paymentMode')) { needed.push({ name: 'paymentMode', type: 'select', required: false, values: ['none', 'code', 'sub', 'canceled'], maxSelect: 1 }); } if (!has('accessCodeId')) { needed.push({ name: 'accessCodeId', type: 'text', required: false }); } if (!has('accessCodeEnteredAt')) { needed.push({ name: 'accessCodeEnteredAt', type: 'date', required: false }); } if (needed.length) { await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] }); } } export async function migrate(): Promise { console.log('[migrate] Checking PB collection schemas...'); await ensureSchema(); // Runs even when the schema already exists (unlike ensureSchema's early // return) so new platform collections/fields/seed land on existing installs. await ensureUserFields(); await ensureFamFields(); await ensureSettingsFields(); await ensureBonusFields(); await ensureTemplateFields(); await ensureAssignedChoreFields(); await ensureFamReadRules(); await ensureAccessCodes(); await ensurePlatform(); // Superuser-only collections also land on EXISTING installs (ensureSchema's // early return skips them). Like ensureOtp before it, ensurePins no-ops when // the collection already exists. await ensurePins({}); await ensureDefaultSeasons(); if (await isDemo()) { await seedDemoFamily(); await seedDemoCompletions(); } console.log('[migrate] Done'); } // Children toggle chores with their own token, so fam-scoped collections need // fam read rules on EXISTING installs (fresh installs get them via // SCHEMA_PLAN). Without these, the chore lookup returns empty and the // completions create fails relation validation ("resource cannot be found"). // Idempotent. async function ensureFamReadRules(): Promise { const readRule = 'famId = @request.auth.famId'; for (const name of ['assigned_chores', 'completions', 'rewards']) { const col = await getCollection(name); if (!col) continue; if (col.listRule !== readRule || col.viewRule !== readRule) { await updateCollection(col.id, { ...col, listRule: readRule, viewRule: readRule }); console.log(`[migrate] Fixed read rules on ${name}.`); } } } // Add colour / icon / description to `assigned_chores` on existing installs so // family-admins can override a template's look per assignment. Idempotent. async function ensureAssignedChoreFields(): Promise { const col = await getCollection('assigned_chores'); if (!col) return; const has = (n: string) => col.fields.some((f: any) => f.name === n); const fields = [...col.fields]; const needed: any[] = []; if (!has('description')) needed.push({ name: 'description', type: 'text', required: false }); if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false }); if (!has('color')) needed.push({ name: 'color', type: 'text', required: false }); if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false }); // Shared chores: memberId becomes optional, shared boolean tracks original mode if (!has('shared')) needed.push({ name: 'shared', type: 'bool', required: false }); let changed = false; if (needed.length) { fields.push(...needed); changed = true; } // Todos can be celebration-only (emoji) as well as points/money. const typeField = fields.find((f: any) => f.name === 'type'); if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) { typeField.values = [...typeField.values, 'emoji']; changed = true; } // Direct todos (created by a parent, not from a template) have no templateId. const tplField = fields.find((f: any) => f.name === 'templateId'); if (tplField && tplField.required) { tplField.required = false; changed = true; } // memberId must be optional for shared chores (null = shared with all members) const memberField = fields.find((f: any) => f.name === 'memberId'); if (memberField && memberField.required) { memberField.required = false; changed = true; } if (changed) { await updateCollection(col.id, { ...col, fields }); } const comp = await getCollection('completions'); if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) { await updateCollection(comp.id, { ...comp, fields: [...comp.fields, { name: 'rewardId', type: 'text', required: false }] }); } } // Add the pocket-money fields to bonus collections on existing installs, and // backfill a default pocket-money droplet (£10 / 50% chores) for any child that // doesn't yet have one. Idempotent. async function ensureBonusFields(): Promise { for (const name of ['bonus_templates', 'bonus_configs']) { const col = await getCollection(name); if (!col) continue; const has = (n: string) => col.fields.some((f: any) => f.name === n); let changed = false; const fields = [...col.fields]; if (!has('thresholdType')) { fields.push({ name: 'thresholdType', type: 'select', required: false, values: ['points', 'percent'], maxSelect: 1 }); changed = true; } if (!has('isPocketMoney')) { fields.push({ name: 'isPocketMoney', type: 'bool', required: false }); changed = true; } // Standalone-reward progress window (bonus_configs only): how far progress // counts (`completeBy`) and the window's start/custom-end dates. if (name === 'bonus_configs') { if (!has('completeBy')) { fields.push({ name: 'completeBy', type: 'select', required: false, values: ['unlimited', 'week', 'custom'], maxSelect: 1 }); changed = true; } if (!has('startDate')) { fields.push({ name: 'startDate', type: 'text', required: false }); changed = true; } if (!has('completeByDate')) { fields.push({ name: 'completeByDate', type: 'text', required: false }); changed = true; } } if (name === 'bonus_configs' && !has('targetChoreId')) { fields.push({ name: 'targetChoreId', type: 'text', required: false }); changed = true; } if (name === 'bonus_configs' && !has('targetChoreIds')) { fields.push({ name: 'targetChoreIds', type: 'json', required: false }); changed = true; } // Completion audit (bonus_configs only): who/when a goal was finished — // stamped by the manual "Mark as complete" action or automatically when // a `once` goal's reward is approved. if (name === 'bonus_configs' && !has('completedBy')) { fields.push({ name: 'completedBy', type: 'text', required: false }); changed = true; } if (name === 'bonus_configs' && !has('completedAt')) { fields.push({ name: 'completedAt', type: 'date', required: false }); changed = true; } // rewardValue must be optional so an unset pocket-money droplet can exist. const rv = fields.find((f: any) => f.name === 'rewardValue'); if (rv && rv.required) { rv.required = false; changed = true; } if (changed) await updateCollection(col.id, { ...col, fields }); } await backfillPocketMoney(); } async function backfillPocketMoney(): Promise { const t = await auth(); // Flag legacy pocket-money configs that predate the isPocketMoney field so // the dashboard recognises them (and we don't create duplicates below). const legacyRes = await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=${encodeURIComponent( "isPocketMoney!=true && name~'pocket' && thresholdType='percent'" )}&perPage=500`, { headers: { Authorization: `Bearer ${t}` } } ); for (const rec of (await legacyRes.json())?.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({ isPocketMoney: true }) }).catch(() => {}); } const childrenRes = await fetch( `${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`, { headers: { Authorization: `Bearer ${t}` } } ); const children: any[] = (await childrenRes.json())?.items || []; for (const c of children) { const existingRes = await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=famId='${c.famId}' && memberId='${c.id}' && isPocketMoney=true`, { headers: { Authorization: `Bearer ${t}` } } ); if ((await existingRes.json())?.items?.length) continue; await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ famId: c.famId, name: 'Pocket Money', target: 'individual', type: 'threshold', thresholdType: 'percent', occurrence: 'recurring', rewardType: 'cash', rewardValue: 10, criteriaValue: 50, memberId: c.id, period: 'weekly', status: 'active', isPocketMoney: true }) }).catch(() => {}); } console.log('[migrate] Pocket-money droplets ensured.'); } // Promote chore_templates / bonus_templates to platform-owned: add global/icon/ // color fields, make famId optional, and relax read rules so any family can see // global (platform) templates as assignable droplets. Idempotent. Seeds a // starter set of global templates on first run for out-of-the-box usage. async function ensureTemplateFields(): Promise { const readRule = 'global = true || famId = @request.auth.famId'; for (const name of ['chore_templates', 'bonus_templates']) { const col = await getCollection(name); if (!col) continue; const has = (n: string) => col.fields.some((f: any) => f.name === n); let changed = false; const fields = [...col.fields]; for (const f of [ { name: 'global', type: 'bool', required: false }, { name: 'icon', type: 'text', required: false }, { name: 'color', type: 'text', required: false } ]) { if (!has(f.name)) { fields.push(f); changed = true; } } const famId = fields.find((f: any) => f.name === 'famId'); if (famId && famId.required) { famId.required = false; changed = true; } if (col.listRule !== readRule || col.viewRule !== readRule) { col.listRule = readRule; col.viewRule = readRule; changed = true; } if (changed) await updateCollection(col.id, { ...col, fields }); } await seedGlobalTemplates(); } const GLOBAL_TEMPLATE_ICONS = [ 'Bed', 'Sparkles', 'BookOpen', 'Utensils', 'Wallet', 'Trophy', 'Star', 'Moon', 'Sun', 'Apple', 'Car', 'Gamepad2', 'Music', 'Shirt', 'Leaf', 'Heart', 'Gift', 'Timer' ]; async function seedGlobalTemplates(): Promise { const t = await auth(); const countGlobal = async (name: string) => { const res = await fetch( `${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`, { headers: { Authorization: `Bearer ${t}` } } ); return (await res.json())?.items?.length || 0; }; if ((await countGlobal('chore_templates')) > 0 || (await countGlobal('bonus_templates')) > 0) { console.log('[migrate] Global templates already present — skipping seed.'); return; } const choreSeeds = [ { name: 'Make Bed', defaultFrequency: 'daily', defaultType: 'points', defaultValue: 2, icon: 'Bed', color: '#6366f1' }, { name: 'Tidy Room', defaultFrequency: 'weekly', defaultType: 'points', defaultValue: 10, icon: 'Sparkles', color: '#8b5cf6' }, { name: 'Homework', defaultFrequency: 'daily', defaultType: 'points', defaultValue: 5, icon: 'BookOpen', color: '#0ea5e9' }, { name: 'Set Table', defaultFrequency: 'daily', defaultType: 'money', defaultValue: 1, icon: 'Utensils', color: '#10b981' } ]; const bonusSeeds = [ { name: 'Pocket Money', target: 'individual', type: 'threshold', thresholdType: 'percent', occurrence: 'recurring', rewardType: 'cash', rewardValue: '', criteriaValue: 50, period: 'weekly', isPocketMoney: true, icon: 'Wallet', color: '#22c55e' }, { name: 'Reading Bonus', target: 'individual', type: 'count', occurrence: 'recurring', rewardType: 'points', rewardValue: '50', criteriaValue: 5, period: 'weekly', icon: 'BookOpen', color: '#f59e0b' }, { name: 'Star of the Week', target: 'competitive', type: 'threshold', thresholdType: 'points', occurrence: 'weekly', rewardType: 'prize', rewardValue: 'Treat', criteriaValue: 100, period: 'weekly', icon: 'Trophy', color: '#ef4444' } ]; for (const s of choreSeeds) { await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ ...s, global: true }) }).catch(() => {}); } for (const s of bonusSeeds) { await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ ...s, global: true }) }).catch(() => {}); } console.log('[migrate] Seeded global templates.'); } // ── Demo family seed ──────────────────────────────────────────────────────── // Creates a "Showboaters" family with one admin (demo) and three kids // (Juno, Jackson, Jalipo). Idempotent — skips if the family already exists. // The parent password is `demo1234` (PB enforces ≥8 chars). Children's // derived passwords are handled by the normal member-otp flow. const DEMO_FAM = { name: 'Showboaters', slug: 'showboaters', parentName: 'Demo', parentEmail: 'demo@famdone.xyz', parentPassword: 'demo1234', children: [ { name: 'Juno', color: '#f472b6' }, { name: 'Jackson', color: '#60a5fa' }, { name: 'Jalipo', color: '#34d399' } ] }; async function seedDemoFamily(): Promise { const t = await auth(); // Check if the demo family already exists const famRes = await fetch( `${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`, { headers: { Authorization: `Bearer ${t}` } } ); const famData = await famRes.json(); if (famData?.items?.length) return; // already seeded console.log('[migrate] Seeding demo family "Showboaters"...'); // 1. Create the family const createdFam = await fetch(`${PB_ENDPOINT}/api/collections/fams/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ name: DEMO_FAM.name, slug: DEMO_FAM.slug, timezone: 'auto', paymentMode: 'none', active: false }) }); const fam = await createdFam.json(); if (!createdFam.ok) throw new Error(`Seed demo fam failed: ${JSON.stringify(fam)}`); const famId = fam.id; // 2. Create the parent (admin) user const parentUsername = `${DEMO_FAM.slug}:${DEMO_FAM.parentName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; const createdParent = await fetch(`${PB_ENDPOINT}/api/collections/users/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ username: parentUsername, name: DEMO_FAM.parentName, email: DEMO_FAM.parentEmail, emailVisibility: false, password: DEMO_FAM.parentPassword, passwordConfirm: DEMO_FAM.parentPassword, famId, role: 'parent' }) }); if (!createdParent.ok) { const pd = await createdParent.json(); throw new Error(`Seed demo parent failed: ${JSON.stringify(pd)}`); } // 3. Create settings for the family await fetch(`${PB_ENDPOINT}/api/collections/settings/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ famId }) }); // 4. Apply access code (dev123) to unlock the family const codeRes = await fetch( `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='dev123'&perPage=1`, { headers: { Authorization: `Bearer ${t}` } } ); const codeData = await codeRes.json(); const accessCode = codeData?.items?.[0]; if (accessCode) { await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${famId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ paymentMode: 'code', active: true, accessCodeId: accessCode.id, accessCodeEnteredAt: new Date().toISOString() }) }); } // 5. Create child users (via the same derive-password path as member-otp) for (const child of DEMO_FAM.children) { const childHandle = child.name.toLowerCase().replace(/[^a-z0-9]/g, ''); const childUsername = `${DEMO_FAM.slug}:${childHandle}`; const childPassword = `${String(MEMBER_SECRET)}${DEMO_FAM.slug}${childHandle}`; await fetch(`${PB_ENDPOINT}/api/collections/users/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ username: childUsername, name: child.name, color: child.color, password: childPassword, passwordConfirm: childPassword, famId, role: 'child' }) }).catch((e) => { console.warn(`[migrate] Failed to create demo child ${child.name}:`, e); }); // Auto-create pocket-money droplet await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ famId, name: 'Pocket Money', target: 'individual', type: 'threshold', thresholdType: 'percent', occurrence: 'recurring', rewardType: 'cash', rewardValue: '', criteriaValue: 50, period: 'weekly', status: 'active', isPocketMoney: true }) }).catch(() => {}); } console.log('[migrate] ✓ Demo family "Showboaters" seeded'); console.log(`[migrate] Login: ${DEMO_FAM.parentEmail} / ${DEMO_FAM.parentPassword}`); } // ── Demo rotating data ────────────────────────────────────────────────────── // On every boot, if the demo family exists and doesn't already have today's // completions, wipe and regenerate 21 days of deterministic completion data. // This ensures the demo always shows a realistic 3-week history relative to // the current date, without manual re-seeding. // Simple seeded PRNG (mulberry32) so the same data is produced every time. function demoRng(seed: number): () => number { let s = seed | 0; return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function dateStr(d: Date): string { return d.toISOString().slice(0, 10); } function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r; } // Each kid's "completion personality" — Juno is reliable, Jackson solid, Jalipo spotty. // Maps assigned chore customName → weekday (0=Sun) → probability of completion. const COMPLETION_PROFILES: Record>> = { Juno: { 0: { default: 0.85 }, // Sun 1: { default: 0.95 }, // Mon 2: { default: 0.9 }, 3: { default: 0.95 }, 4: { default: 0.9 }, 5: { default: 0.85 }, // Fri 6: { default: 0.8 } // Sat }, Jackson: { 0: { default: 0.7 }, 1: { default: 0.9 }, 2: { default: 0.85 }, 3: { default: 0.8 }, 4: { default: 0.75 }, 5: { default: 0.7 }, 6: { default: 0.6 } }, Jalipo: { 0: { default: 0.4 }, 1: { default: 0.75 }, 2: { default: 0.65 }, 3: { default: 0.7 }, 4: { default: 0.6 }, 5: { default: 0.5 }, 6: { default: 0.35 } } }; async function seedDemoCompletions(): Promise { const t = await auth(); // Find the demo family const famRes = await fetch( `${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`, { headers: { Authorization: `Bearer ${t}` } } ); const famData = await famRes.json(); const fam = famData?.items?.[0]; if (!fam) return; // demo family not seeded yet const famId = fam.id; // Check if today's completions already exist (idempotent) const today = dateStr(new Date()); const todayComps = await fetch( `${PB_ENDPOINT}/api/collections/completions/records?filter=${encodeURIComponent(`famId='${famId}' && date='${today}'`)}&perPage=1`, { headers: { Authorization: `Bearer ${t}` } } ); if ((await todayComps.json())?.items?.length) { console.log('[migrate] Demo data already rotated for today — skipping.'); return; } console.log('[migrate] Rotating demo data for Showboaters...'); // Fetch members, assigned chores const [membersRes, assignedRes] = await Promise.all([ fetch( `${PB_ENDPOINT}/api/collections/users/records?filter=${encodeURIComponent(`famId='${famId}' && role='child'`)}&perPage=10`, { headers: { Authorization: `Bearer ${t}` } } ), fetch( `${PB_ENDPOINT}/api/collections/assigned_chores/records?filter=famId='${famId}'&perPage=100`, { headers: { Authorization: `Bearer ${t}` } } ) ]); const members: any[] = (await membersRes.json())?.items || []; const assigned: any[] = (await assignedRes.json())?.items || []; if (!members.length || !assigned.length) return; // Delete existing completions and weekly_history for the demo family const [oldCompsRes, oldHistoryRes] = await Promise.all([ fetch( `${PB_ENDPOINT}/api/collections/completions/records?filter=famId='${famId}'&perPage=500`, { headers: { Authorization: `Bearer ${t}` } } ), fetch( `${PB_ENDPOINT}/api/collections/weekly_history/records?filter=famId='${famId}'&perPage=500`, { headers: { Authorization: `Bearer ${t}` } } ) ]); const oldComps: any[] = (await oldCompsRes.json())?.items || []; const oldHistory: any[] = (await oldHistoryRes.json())?.items || []; for (const r of oldComps) { await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${r.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }).catch(() => {}); } for (const r of oldHistory) { await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records/${r.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }).catch(() => {}); } // Generate 21 days of completions (today - 20 → today) const todayDate = new Date(); todayDate.setUTCHours(12, 0, 0, 0); // anchor at noon UTC const startDate = addDays(todayDate, -20); // Track per-member-per-week totals for weekly_history type WeekKey = string; // "YYYY-MM-DD" (week start: day after payday) const weekTotals: Record< string, Record > = {}; const demoPayday = fam.payday ?? 1; for (const member of members) { const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo']; for (let d = 0; d < 21; d++) { const day = addDays(startDate, d); const ds = dateStr(day); const dow = day.getUTCDay(); // 0=Sun // Compute week start (payday-anchored: weeks close on payday, so // the bucket opens the day after — same rule as weekStart()). const dowNum = day.getUTCDay(); // 0=Sun const sincePayday = (((dowNum - demoPayday) % 7) + 7) % 7; const back = sincePayday === 0 ? 6 : sincePayday - 1; const wsDay = new Date(day); wsDay.setUTCDate(wsDay.getUTCDate() - back); const ws = dateStr(wsDay); if (!weekTotals[member.id]) weekTotals[member.id] = {}; if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 }; const dayProfile = profile[dow] || profile[0]; // Find this member's assigned chores const memberChores = assigned.filter( (a: any) => a.memberId === member.id || (!a.memberId && a.shared) ); for (const chore of memberChores) { // Weekly chores: only complete on the week's first day // (day after payday). if (chore.frequency === 'weekly' && dow !== (demoPayday + 1) % 7) continue; const prob = dayProfile[chore.customName] || dayProfile.default; const rng = demoRng( famId.length * 1000 + member.id.length * 100 + d * 10 + (chore.id?.charCodeAt(0) || 0) ); if (rng() > prob) continue; // missed this day // Create completion record await fetch(`${PB_ENDPOINT}/api/collections/completions/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ famId, memberId: member.id, assignedChoreId: chore.id, date: ds }) }).catch(() => {}); // Track totals const val = Number(chore.value) || 0; if (chore.type === 'points') { weekTotals[member.id][ws].points += val; } else if (chore.type === 'money') { weekTotals[member.id][ws].money += val; } weekTotals[member.id][ws].count++; } } } // Create weekly_history records for each week that has data for (const memberId of Object.keys(weekTotals)) { for (const ws of Object.keys(weekTotals[memberId])) { const totals = weekTotals[memberId][ws]; if (totals.count === 0) continue; await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, body: JSON.stringify({ famId, memberId, weekStart: ws, pointsEarned: totals.points, moneyEarned: totals.money, choresCompleted: totals.count, bonusEarned: 0 }) }).catch(() => {}); } } console.log('[migrate] ✓ Demo completions rotated (21 days)'); } export { GLOBAL_TEMPLATE_ICONS }; export { seedDemoCompletions };