757 lines
24 KiB
TypeScript
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 };
|