Files
famdone/frontend/src/lib/server/migrate.ts
T
2026-09-21 08:35:13 +01:00

1286 lines
42 KiB
TypeScript

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<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
});
const data = await res.json();
if (!res.ok) throw new Error(`PB 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<boolean> {
const flags = await getPlatformFlags();
return flags.demo === true;
}
async function getCollection(name: string): Promise<any | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
headers: { Authorization: `Bearer ${t}` }
});
const data = await res.json();
return data?.items?.[0] || null;
}
async function createCollection(col: any): Promise<string | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col)
});
const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created collection: ${col.name}`);
return data?.id || null;
}
async function updateCollection(id: string, col: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col)
});
const data = await res.json();
if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Updated collection: ${col.name || id}`);
}
async function createRecord(collection: string, data: any): Promise<void> {
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<void> {
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<string, string>): Promise<void> {
const usersCol = await getCollection('users');
if (!usersCol) throw new Error('users collection not found');
const famsId = ids.fams || (await getCollection('fams'))?.id;
if (!famsId) throw new Error('fams collection not found');
const has = (n: string) => usersCol.fields.some((f: any) => f.name === n);
let changed = false;
const emailField = usersCol.fields.find((f: any) => f.name === 'email');
if (emailField && emailField.required) {
emailField.required = false;
changed = true;
}
if (!has('famId')) {
usersCol.fields.push({
name: 'famId',
type: 'relation',
required: false,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
});
changed = true;
}
if (!has('role')) {
usersCol.fields.push({
name: 'role',
type: 'select',
required: false,
values: ['parent', 'child'],
maxSelect: 1
});
changed = true;
}
if (!has('username')) {
usersCol.fields.push({ name: 'username', type: 'text', required: true });
changed = true;
}
if (!has('color')) {
usersCol.fields.push({ name: 'color', type: 'text', required: false });
changed = true;
}
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<string, string>): Promise<void> {
if (await getCollection('otp')) return;
const famsId = ids.fams || (await getCollection('fams'))?.id;
const usersId = ids.users || (await getCollection('users'))?.id;
if (!famsId || !usersId) throw new Error('fams/users collection not found');
await createCollection({
name: 'otp',
type: 'base',
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: 'famId',
type: 'relation',
required: true,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
},
{
name: 'userId',
type: 'relation',
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: false
},
{ name: 'otp', type: 'text', required: false },
{ name: 'updatedAt', type: 'text', required: false }
]
});
}
// 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<string, string>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (await getCollection('fams')) {
console.log('[migrate] Schema already present — skipping bootstrap.');
return;
}
console.log('[migrate] Bootstrapping schema on fresh PocketBase...');
const ids: Record<string, string> = {};
const nativeUsers = await getCollection('users');
if (nativeUsers) ids.users = nativeUsers.id;
for (const entry of SCHEMA_PLAN) {
const createdId = await createCollection(entry.build(ids));
if (createdId) ids[entry.name] = createdId;
}
await ensureUsers(ids);
await ensureOtp(ids);
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, Record<number, Record<string, number>>> = {
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<void> {
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<string, { points: number; money: number; count: number }>
> = {};
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 };