Files
famdone/frontend/src/lib/server/migrate.ts
T
2026-09-07 19:02:43 +01:00

757 lines
24 KiB
TypeScript

import { SCHEMA_PLAN } from '@shared/pb/schema';
import { PB_ENDPOINT } from '$lib/server/pocketbase';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
let token: string | null = null;
async function auth(): Promise<string> {
if (token) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
});
const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token;
return token!;
}
async function getCollection(name: string): Promise<any | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
headers: { Authorization: `Bearer ${t}` }
});
const data = await res.json();
return data?.items?.[0] || null;
}
async function createCollection(col: any): Promise<string | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col)
});
const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created collection: ${col.name}`);
return data?.id || null;
}
async function updateCollection(id: string, col: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col)
});
const data = await res.json();
if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Updated collection: ${col.name || id}`);
}
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('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 }
]
});
}
// 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 } })
});
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);
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<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 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<void> {
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<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;
}
// 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.');
}
export { GLOBAL_TEMPLATE_ICONS };