import { SCHEMA_PLAN } from '@shared/pb/schema'; import { PB_ENDPOINT } from '$lib/server/pocketbase'; import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; let token: string | null = null; async function auth(): Promise { 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}`); 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('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 } ] }); } // 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 } }) }); 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); console.log('[migrate] Schema bootstrapped.'); } // 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 ensureFamFields(); await ensureBonusFields(); await ensureTemplateFields(); await ensureAssignedChoreFields(); await ensureAccessCodes(); await ensurePlatform(); await ensureDefaultSeasons(); console.log('[migrate] Done'); } // 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 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 }); // Todos can be celebration-only (emoji) as well as points/money. let changed = !!needed.length; const typeField = col.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 = col.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 = col.fields.find((f: any) => f.name === 'memberId'); if (memberField && memberField.required) { memberField.required = false; changed = true; } if (changed) { await updateCollection(col.id, { ...col, fields: [...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; } // 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.'); } export { GLOBAL_TEMPLATE_ICONS };