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}`); } // Bootstrap the full base schema on a fresh PocketBase (docker first boot). // Idempotent — skips collections that already exist. Schema is shared with // seed.ts via shared/pb/schema.ts. async function ensureSchema(): Promise { if (await getCollection("fams")) { console.log("[migrate] Base schema already present — skipping bootstrap."); return; } console.log("[migrate] Bootstrapping base schema on fresh PocketBase..."); const ids: Record = {}; // `users` is PocketBase's native auth collection (created on first boot), // not part of SCHEMA_PLAN. Pre-register its id so relations can point at it. 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; } // fams is sensitive → superadmin-only (proxy/server reads). Not public. const famsId = ids.fams; if (famsId) await updateCollection(famsId, { viewRule: null, listRule: null }); console.log("[migrate] Base schema bootstrapped."); } export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); await ensureSchema(); // ── 0. Lock fams to superadmin-only (sensitive; read via proxy/server) ── { const famsCol = await getCollection("fams"); if (famsCol) { const rules = { viewRule: null, listRule: null }; if (famsCol.viewRule !== null || famsCol.listRule !== null) { console.log("[migrate] Locking fams collection to superadmin-only..."); await updateCollection(famsCol.id, rules); } else { console.log(" ↳ fams already superadmin-only"); } } } // ── 1. Create bonus_configs if missing ── const existing = await getCollection("bonus_configs"); if (!existing) { // Need to get fams collection ID first const famsCol = await getCollection("fams"); if (!famsCol) throw new Error("fams collection not found"); const famsId = famsCol.id; console.log("[migrate] Creating bonus_configs collection..."); await createCollection({ name: "bonus_configs", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false, }, { name: "name", type: "text", required: true }, { name: "description", type: "text", required: false }, { name: "target", type: "select", required: true, values: ["individual", "competitive", "collaborative"], maxSelect: 1, }, { name: "type", type: "select", required: true, values: ["threshold", "count", "manual"], maxSelect: 1, }, { name: "occurrence", type: "select", required: true, values: ["recurring", "once"], maxSelect: 1, }, { name: "rewardType", type: "select", required: true, values: ["points", "cash", "prize"], maxSelect: 1, }, { name: "rewardValue", type: "text", required: true }, { name: "criteriaValue", type: "number", required: false }, { name: "period", type: "select", required: false, values: ["schedule", "daily", "weekly", "monthly"], maxSelect: 1, }, { name: "status", type: "select", required: true, values: ["active", "archived"], maxSelect: 1, }, ], }); } else { console.log(` ↳ bonus_configs already exists`); const targetField = existing.fields.find((f: any) => f.name === "target"); const periodField = existing.fields.find((f: any) => f.name === "period"); const needTargetUpdate = targetField && !targetField.values.includes("collaborative"); const needPeriodUpdate = periodField && !periodField.values.includes("daily"); if (needTargetUpdate || needPeriodUpdate) { console.log("[migrate] Updating bonus_configs fields..."); if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"]; if (needPeriodUpdate) periodField.values = ["schedule", "daily", "weekly", "monthly"]; await updateCollection(existing.id, { name: "bonus_configs", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: existing.fields, }); } } // ── 2. Update rewards collection ── const rewardsCol = await getCollection("rewards"); if (rewardsCol) { const fieldNames = rewardsCol.fields.map((f: any) => f.name); const needsUpdate = !fieldNames.includes("date") || fieldNames.includes("autoClaimed") || fieldNames.includes("weekStart") || fieldNames.includes("month"); if (needsUpdate) { console.log("[migrate] Updating rewards collection schema..."); const bonusConfigsCol = await getCollection("bonus_configs"); const keepFields = rewardsCol.fields.filter((f: any) => [ "famId", "memberId", "label", "value", "claimed", "claimedAt", "rewardType", "bonusConfigId", "created", "updated", "id", ].includes(f.name), ); const newFields = [ ...keepFields, ...(bonusConfigsCol && !fieldNames.includes("bonusConfigId") ? [ { name: "bonusConfigId", type: "relation", required: false, collectionId: bonusConfigsCol.id, maxSelect: 1, cascadeDelete: false, }, ] : []), ...(fieldNames.includes("rewardType") ? [] : [ { name: "rewardType", type: "select", required: true, values: ["cash", "prize", "points"], maxSelect: 1, }, ]), ...(fieldNames.includes("claimedAt") ? [] : [{ name: "claimedAt", type: "date", required: false }]), { name: "date", type: "text", required: false }, ]; await updateCollection(rewardsCol.id, { name: "rewards", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: newFields, }); // Backfill existing rewards const today = new Date().toISOString().slice(0, 10); const backfillRes = await fetch( `${PB_ENDPOINT}/api/collections/rewards/records?perPage=200`, { headers: { Authorization: `Bearer ${await auth()}` } }, ); const backfillData = await backfillRes.json(); if (backfillData?.items) { for (const r of backfillData.items) { const t2 = await auth(); const patches: Record = {}; if (!r.date) { patches.date = r.claimedAt?.slice(0, 10) || today; } if (r.rewardType === "points" && !r.claimed) { patches.claimed = true; patches.claimedAt = new Date().toISOString(); } if (Object.keys(patches).length > 0) { await fetch( `${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t2}`, }, body: JSON.stringify(patches), }, ); } } console.log(` ✓ Backfilled ${backfillData.items.length} rewards`); } } else { console.log(` ↳ rewards schema is current`); } } else { console.log(` ↳ rewards collection not found (will be created by seed)`); } // Ensure rewards.claimable + rewards.settleDate exist (payday-gated bonuses) const rewardsCol2 = await getCollection("rewards"); if (rewardsCol2) { const fieldNames = rewardsCol2.fields.map((f: any) => f.name); const missing: any[] = []; if (!fieldNames.includes("claimable")) { missing.push({ name: "claimable", type: "select", required: true, values: ["immediate", "payday"], maxSelect: 1, }); } if (!fieldNames.includes("settleDate")) { missing.push({ name: "settleDate", type: "text", required: false }); } if (missing.length) { console.log( "[migrate] Adding rewards.claimable/settleDate (payday gating)...", ); rewardsCol2.fields.push(...missing); await updateCollection(rewardsCol2.id, { name: "rewards", type: "base", listRule: rewardsCol2.listRule, viewRule: rewardsCol2.viewRule, createRule: rewardsCol2.createRule, updateRule: rewardsCol2.updateRule, deleteRule: rewardsCol2.deleteRule, fields: rewardsCol2.fields, }); console.log(" ✓ rewards.claimable/settleDate added"); } else { console.log(` ↳ rewards.claimable/settleDate already exist`); } } // ── 3. Update settings collection (drop old fields) ── const settingsCol = await getCollection("settings"); if (settingsCol) { const fieldNames = settingsCol.fields.map((f: any) => f.name); if ( fieldNames.includes("pointsThreshold") || fieldNames.includes("weeklyBonus") ) { console.log( "[migrate] Updating settings collection (dropping old fields)...", ); const keepFields = settingsCol.fields.filter((f: any) => ["famId", "webhookUrl", "created", "updated", "id"].includes(f.name), ); await updateCollection(settingsCol.id, { name: "settings", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: keepFields, }); } else { console.log(` ↳ settings schema is current`); } // Ensure simulateEow debug flag exists (read-only EOW preview toggle) const simField = settingsCol.fields.some( (f: any) => f.name === "simulateEow", ); if (!simField) { console.log("[migrate] Adding settings.simulateEow (debug toggle)..."); settingsCol.fields.push({ name: "simulateEow", type: "bool" }); await updateCollection(settingsCol.id, { name: "settings", type: "base", listRule: settingsCol.listRule, viewRule: settingsCol.viewRule, createRule: settingsCol.createRule, updateRule: settingsCol.updateRule, deleteRule: settingsCol.deleteRule, fields: settingsCol.fields, }); } else { console.log(` ↳ settings.simulateEow already exists`); } } // ── 5. Add payday field to fams if missing ── const famsCol = await getCollection("fams"); if (famsCol) { const hasPayday = famsCol.fields.some((f: any) => f.name === "payday"); if (!hasPayday) { console.log("[migrate] Adding payday field to fams..."); const paydayField = { name: "payday", type: "number", required: false, min: 0, max: 6, }; famsCol.fields.push(paydayField); await updateCollection(famsCol.id, { name: "fams", type: "base", listRule: famsCol.listRule, viewRule: famsCol.viewRule, createRule: famsCol.createRule, updateRule: famsCol.updateRule, deleteRule: famsCol.deleteRule, fields: famsCol.fields, }); } else { console.log(` ↳ fams.payday already exists`); } } // ── 5b. Add lastIssued (payday heartbeat) field to fams if missing ── { const fc = famsCol || (await getCollection("fams")); if (fc) { const hasLastIssued = fc.fields.some((f: any) => f.name === "lastIssued"); if (!hasLastIssued) { console.log("[migrate] Adding lastIssued field to fams..."); fc.fields.push({ name: "lastIssued", type: "text" }); await updateCollection(fc.id, { name: "fams", type: "base", listRule: fc.listRule, viewRule: fc.viewRule, createRule: fc.createRule, updateRule: fc.updateRule, deleteRule: fc.deleteRule, fields: fc.fields, }); } else { console.log(` ↳ fams.lastIssued already exists`); } } } // ── 5c. Add paydayTime (HH:MM) field to fams if missing ── { const fc = famsCol || (await getCollection("fams")); if (fc) { const hasPaydayTime = fc.fields.some((f: any) => f.name === "paydayTime"); if (!hasPaydayTime) { console.log("[migrate] Adding paydayTime field to fams..."); fc.fields.push({ name: "paydayTime", type: "text" }); await updateCollection(fc.id, { name: "fams", type: "base", listRule: fc.listRule, viewRule: fc.viewRule, createRule: fc.createRule, updateRule: fc.updateRule, deleteRule: fc.deleteRule, fields: fc.fields, }); } else { console.log(` ↳ fams.paydayTime already exists`); } } } // ── 5d. Add timezone (IANA name or "auto") field to fams if missing ── { const fc = famsCol || (await getCollection("fams")); if (fc) { const hasTz = fc.fields.some((f: any) => f.name === "timezone"); if (!hasTz) { console.log("[migrate] Adding timezone field to fams..."); fc.fields.push({ name: "timezone", type: "text", max: 64, pattern: "", autogeneratePattern: "", primaryKey: false, system: false, required: false, unique: false, hidden: false, presentable: false, noDecimal: false, }); await updateCollection(fc.id, { name: "fams", type: "base", listRule: fc.listRule, viewRule: fc.viewRule, createRule: fc.createRule, updateRule: fc.updateRule, deleteRule: fc.deleteRule, fields: fc.fields, }); } else { console.log(` ↳ fams.timezone already exists`); } } } // ── 5e. Backfill fams.timezone = "auto" for any existing fams ── try { const t = await auth(); const res = await fetch( `${PB_ENDPOINT}/api/collections/fams/records?perPage=200&filter=${encodeURIComponent( `timezone = "" || timezone = null`, )}`, { headers: { Authorization: `Bearer ${t}` } }, ); const data = await res.json(); const fams = data?.items || []; for (const f of fams) { await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${f.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ timezone: "auto" }), }); } if (fams.length) { console.log( ` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`, ); } } catch (err) { console.log( " ↳ timezone backfill skipped:", err instanceof Error ? err.message : err, ); } // ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ── // PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date` // and just normalise existing records. The frontend reads with .slice(0, 10) either way. try { const completionsCol = await getCollection("completions"); if (completionsCol) { const t = await auth(); const all = await fetch( `${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const data = await all.json(); if (data?.items?.length) { let backfilled = 0; for (const rec of data.items) { const plain = rec.date?.slice(0, 10); if (plain && plain !== rec.date) { await fetch( `${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ date: plain }), }, ); backfilled++; } } if (backfilled > 0) console.log(` ✓ Backfilled ${backfilled} completion dates`); else console.log(` ↳ completions.date already normalised`); } } } catch (e) { console.log( ` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`, ); } // ── 7. Add memberId field to bonus_configs ── const bonusConfigsCol = await getCollection("bonus_configs"); if (bonusConfigsCol) { const hasMemberId = bonusConfigsCol.fields.some( (f: any) => f.name === "memberId", ); if (!hasMemberId) { const membersCol = await getCollection("members"); if (membersCol) { console.log("[migrate] Adding memberId field to bonus_configs..."); bonusConfigsCol.fields.push({ name: "memberId", type: "relation", required: false, collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false, }); await updateCollection(bonusConfigsCol.id, { name: "bonus_configs", type: "base", listRule: bonusConfigsCol.listRule, viewRule: bonusConfigsCol.viewRule, createRule: bonusConfigsCol.createRule, updateRule: bonusConfigsCol.updateRule, deleteRule: bonusConfigsCol.deleteRule, fields: bonusConfigsCol.fields, }); } } else { console.log(` ↳ bonus_configs.memberId already exists`); } } // ── 8. (Removed) bonus_configs.phase field was dropped — replaced by status: active|completed. // Templates now live in a dedicated `bonus_templates` collection (see step 23+). // ── 9. Add role + userId fields to members ── const membersCol = await getCollection("members"); if (membersCol) { const hasRole = membersCol.fields.some((f: any) => f.name === "role"); const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); if (!hasRole || !hasUserId) { console.log("[migrate] Adding role/userId fields to members..."); if (!hasRole) membersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1, }); if (!hasUserId) membersCol.fields.push({ name: "userId", type: "text", required: false, }); await updateCollection(membersCol.id, { name: "members", type: "base", listRule: membersCol.listRule, viewRule: membersCol.viewRule, createRule: membersCol.createRule, updateRule: membersCol.updateRule, deleteRule: membersCol.deleteRule, fields: membersCol.fields, }); } else { console.log(` ↳ members.role/userId already exists`); } } // ── 10. Add email field to members ── if (membersCol) { const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); if (!hasEmail) { console.log("[migrate] Adding email field to members..."); membersCol.fields.push({ name: "email", type: "text", required: false }); await updateCollection(membersCol.id, { name: "members", type: "base", listRule: membersCol.listRule, viewRule: membersCol.viewRule, createRule: membersCol.createRule, updateRule: membersCol.updateRule, deleteRule: membersCol.deleteRule, fields: membersCol.fields, }); } else { console.log(` ↳ members.email already exists`); } } // ── 11. Backfill userId on existing member records ── if (membersCol) { try { const t = await auth(); const allMembers = await fetch( `${PB_ENDPOINT}/api/collections/members/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const membersData = await allMembers.json(); const needBackfill = (membersData?.items || []).filter( (m: any) => !m.userId, ); if (needBackfill.length > 0) { console.log( `[migrate] Backfilling userId for ${needBackfill.length} members...`, ); const adminsRes = await fetch( `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const adminsData = await adminsRes.json(); const adminsByFam = new Map(); for (const a of adminsData?.items || []) { const list = adminsByFam.get(a.famId) || []; list.push(a); adminsByFam.set(a.famId, list); } let count = 0; for (const m of needBackfill) { const admins = adminsByFam.get(m.famId) || []; if (admins.length === 1) { await fetch( `${PB_ENDPOINT}/api/collections/members/records/${m.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ userId: admins[0].userId }), }, ); count++; } } if (count > 0) console.log(` ✓ Backfilled ${count} member userIds`); if (count < needBackfill.length) console.log( ` ↳ Skipped ${needBackfill.length - count} members (no unique fam_admin match)`, ); } else { console.log(` ↳ members.userId already backfilled`); } } catch (e) { console.log( ` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`, ); } } // ── 12. Drop userId field from members ── if (membersCol) { const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); if (hasUserId) { console.log("[migrate] Dropping userId field from members..."); membersCol.fields = membersCol.fields.filter( (f: any) => f.name !== "userId", ); await updateCollection(membersCol.id, { name: "members", type: "base", listRule: membersCol.listRule, viewRule: membersCol.viewRule, createRule: membersCol.createRule, updateRule: membersCol.updateRule, deleteRule: membersCol.deleteRule, fields: membersCol.fields, }); } else { console.log(` ↳ members.userId already removed`); } } // ── 13. Add name + color fields to fam_admins ── const adminsCol = await getCollection("fam_admins"); if (adminsCol) { const hasName = adminsCol.fields.some((f: any) => f.name === "name"); const hasColor = adminsCol.fields.some((f: any) => f.name === "color"); if (!hasName || !hasColor) { console.log("[migrate] Adding name/color to fam_admins..."); if (!hasName) adminsCol.fields.push({ name: "name", type: "text", required: false }); if (!hasColor) adminsCol.fields.push({ name: "color", type: "text", required: false }); await updateCollection(adminsCol.id, { name: "fam_admins", type: "base", listRule: adminsCol.listRule || "", viewRule: adminsCol.viewRule || "", createRule: adminsCol.createRule, updateRule: adminsCol.updateRule, deleteRule: adminsCol.deleteRule, fields: adminsCol.fields, }); // Backfill name from email prefix const t = await auth(); const all = await fetch( `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const data = await all.json(); for (const a of data?.items || []) { const patch: Record = {}; if (!a.name) patch.name = (a.email || "admin").split("@")[0]; if (!a.color) patch.color = "#6366f1"; if (Object.keys(patch).length) { await fetch( `${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify(patch), }, ); } } console.log( ` ✓ Backfilled name/color for ${(data?.items || []).length} admins`, ); } else { console.log(` ↳ fam_admins.name/color already exists`); } } // ── 14. Drop role field from members ── if (membersCol) { const hasRole = membersCol.fields.some((f: any) => f.name === "role"); if (hasRole) { console.log("[migrate] Dropping role field from members..."); membersCol.fields = membersCol.fields.filter( (f: any) => f.name !== "role", ); await updateCollection(membersCol.id, { name: "members", type: "base", listRule: membersCol.listRule, viewRule: membersCol.viewRule, createRule: membersCol.createRule, updateRule: membersCol.updateRule, deleteRule: membersCol.deleteRule, fields: membersCol.fields, }); } else { console.log(` ↳ members.role already removed`); } } // ── 15. Drop email field from members ── if (membersCol) { const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); if (hasEmail) { console.log("[migrate] Dropping email field from members..."); membersCol.fields = membersCol.fields.filter( (f: any) => f.name !== "email", ); await updateCollection(membersCol.id, { name: "members", type: "base", listRule: membersCol.listRule, viewRule: membersCol.viewRule, createRule: membersCol.createRule, updateRule: membersCol.updateRule, deleteRule: membersCol.deleteRule, fields: membersCol.fields, }); } else { console.log(` ↳ members.email already removed`); } } // ── 16. Add seasons json field to fams ── { const c = await getCollection("fams"); if (c) { const hasField = c.fields.some((f: any) => f.name === "seasons"); if (!hasField) { console.log("[migrate] Adding seasons to fams..."); c.fields.push({ name: "seasons", type: "json" }); await updateCollection(c.id, { name: "fams", type: "base", listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, fields: c.fields, }); } else { console.log(` ↳ fams.seasons already exists`); } } } // ── 17. Add seasonIds json field to assigned_chores ── { const c = await getCollection("assigned_chores"); if (c) { const hasField = c.fields.some((f: any) => f.name === "seasonIds"); if (!hasField) { console.log("[migrate] Adding seasonIds to assigned_chores..."); c.fields.push({ name: "seasonIds", type: "json" }); await updateCollection(c.id, { name: "assigned_chores", type: "base", listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, fields: c.fields, }); } else { console.log(` ↳ assigned_chores.seasonIds already exists`); } } } // ── 18. Create seasons collection if missing ── { const existing = await getCollection("seasons"); if (!existing) { console.log("[migrate] Creating seasons collection..."); const t = await auth(); const famsCol = await getCollection("fams"); const res = await fetch(`${PB_ENDPOINT}/api/collections`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ name: "seasons", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "famId", type: "relation", required: true, maxSelect: 1, collectionId: famsCol?.id || "", }, { name: "name", type: "text", required: true }, { name: "color", type: "text" }, { name: "active", type: "bool" }, { name: "autoDisable", type: "date" }, { name: "autoStart", type: "date" }, ], }), }); if (res.ok) console.log(" ✓ Created seasons collection"); else console.log( ` ↳ seasons collection creation skipped or already exists`, ); } else { console.log(` ↳ seasons collection already exists`); } } // ── 19. Add active bool to existing seasons collection ── { const c = await getCollection("seasons"); if (c) { const hasActive = c.fields.some((f: any) => f.name === "active"); if (!hasActive) { console.log("[migrate] Adding active bool to seasons..."); c.fields.push({ name: "active", type: "bool" }); await updateCollection(c.id, { name: "seasons", type: "base", listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, fields: c.fields, }); } else { console.log(` ↳ seasons.active already exists`); } } } // ── 20. Backfill active=true from fams.seasons array ── { const t = await auth(); const fams = await getCollection("fams"); if (fams) { const hasSeasonsField = fams.fields.some( (f: any) => f.name === "seasons", ); if (hasSeasonsField) { console.log( "[migrate] Backfilling season active flags from fams.seasons...", ); const allFams = await fetch( `${PB_ENDPOINT}/api/collections/fams/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const famData = await allFams.json(); for (const fam of famData.items || []) { const activeIds = fam.seasons || []; if (activeIds.length > 0) { for (const sid of activeIds) { await fetch( `${PB_ENDPOINT}/api/collections/seasons/records/${sid}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ active: true }), }, ); } } } console.log(` ↳ backfilled ${famData.items?.length || 0} fams`); } else { console.log(` ↳ fams.seasons already removed`); } } } // ── 21. Remove seasons field from fams ── { const c = await getCollection("fams"); if (c) { const hasField = c.fields.some((f: any) => f.name === "seasons"); if (hasField) { console.log("[migrate] Removing seasons field from fams..."); c.fields = c.fields.filter((f: any) => f.name !== "seasons"); await updateCollection(c.id, { name: "fams", type: "base", listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, fields: c.fields, }); } else { console.log(` ↳ fams.seasons already removed`); } } } // ── 22. Convert rewards.claimed boolean to status select field ── { const c = await getCollection("rewards"); if (c) { const hasClaimedField = c.fields.some((f: any) => f.name === "claimed"); const hasStatusField = c.fields.some((f: any) => f.name === "status"); if (hasClaimedField && !hasStatusField) { console.log("[migrate] Converting rewards.claimed to status field..."); // Fetch all rewards to backfill status const t = await auth(); let page = 1; let total = 0; while (true) { const res = await fetch( `${PB_ENDPOINT}/api/collections/rewards/records?page=${page}&perPage=100`, { headers: { Authorization: `Bearer ${t}` }, }, ); const data = await res.json(); for (const r of data.items || []) { const status = r.claimed ? "claimed" : "unclaimed"; await fetch( `${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ status }), }, ); total++; } if (!data.items || data.items.length < 100) break; page++; } console.log(` ↳ backfilled ${total} rewards with status`); // Remove claimed field and add status + requestedAt fields c.fields = c.fields.filter((f: any) => f.name !== "claimed"); if (!c.fields.some((f: any) => f.name === "status")) { c.fields.push({ name: "status", type: "select", required: true, values: ["unclaimed", "requested", "claimed"], maxSelect: 1, }); } if (!c.fields.some((f: any) => f.name === "requestedAt")) { c.fields.push({ name: "requestedAt", type: "date", required: false }); } await updateCollection(c.id, { name: "rewards", type: "base", listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, fields: c.fields, }); } else if (!hasClaimedField) { console.log(` ↳ rewards.claimed already converted to status`); } } } // ── 23. Create bonus_templates collection if missing ── { const existing = await getCollection("bonus_templates"); if (!existing) { const famsCol = await getCollection("fams"); if (!famsCol) throw new Error("fams collection not found"); console.log("[migrate] Creating bonus_templates collection..."); await createCollection({ name: "bonus_templates", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "famId", type: "relation", required: true, collectionId: famsCol.id, maxSelect: 1, cascadeDelete: false, }, { name: "name", type: "text", required: true }, { name: "description", type: "text", required: false }, { name: "target", type: "select", required: true, values: ["individual", "competitive", "collaborative"], maxSelect: 1, }, { name: "type", type: "select", required: true, values: ["threshold", "count", "manual"], maxSelect: 1, }, { name: "occurrence", type: "select", required: true, values: ["recurring", "once"], maxSelect: 1, }, { name: "rewardType", type: "select", required: true, values: ["points", "cash", "prize"], maxSelect: 1, }, { name: "rewardValue", type: "text", required: true }, { name: "criteriaValue", type: "number", required: false }, { name: "period", type: "select", required: false, values: ["schedule", "daily", "weekly", "monthly"], maxSelect: 1, }, ], }); } else { console.log(` ↳ bonus_templates already exists`); } } // ── 24. Migrate phase='template' bonus_configs → bonus_templates, then delete originals ── { const configsCol = await getCollection("bonus_configs"); const templatesCol = await getCollection("bonus_templates"); if (configsCol && templatesCol) { const t = await auth(); const all = await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const data = await all.json(); const templateRecords = (data?.items || []).filter( (r: any) => r.phase === "template", ); if (templateRecords.length > 0) { console.log( `[migrate] Moving ${templateRecords.length} templates from bonus_configs → bonus_templates...`, ); for (const rec of templateRecords) { const { id, phase, status, completedAt, memberId, created, updated, collectionId, expand, ...fields } = rec; await fetch( `${PB_ENDPOINT}/api/collections/bonus_templates/records`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify(fields), }, ); await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${t}` }, }, ); } console.log(` ✓ Moved ${templateRecords.length} bonus templates`); } else { console.log(` ↳ no phase=template bonus_configs to migrate`); } } } // ── 25. Drop phase/completedAt from bonus_configs; status → active|completed ── { const c = await getCollection("bonus_configs"); if (c) { const hasPhase = c.fields.some((f: any) => f.name === "phase"); const hasCompletedAt = c.fields.some( (f: any) => f.name === "completedAt", ); const statusField = c.fields.find((f: any) => f.name === "status"); const needsStatusUpdate = statusField && !statusField.values.includes("completed"); if (hasPhase || hasCompletedAt || needsStatusUpdate) { console.log( "[migrate] Restructuring bonus_configs (drop phase/completedAt, status → active|completed)...", ); if (statusField && needsStatusUpdate) statusField.values = ["active", "completed"]; c.fields = c.fields.filter( (f: any) => f.name !== "phase" && f.name !== "completedAt", ); await updateCollection(c.id, { name: "bonus_configs", type: "base", listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, fields: c.fields, }); const t = await auth(); const all = await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` }, }, ); const data = await all.json(); let updated = 0; for (const rec of data?.items || []) { const completed = rec.phase === "completed" || rec.status === "archived"; const newStatus = completed ? "completed" : "active"; if (rec.status !== newStatus) { await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ status: newStatus }), }, ); updated++; } } console.log( ` ✓ Updated ${updated} bonus_configs to status=active|completed`, ); } else { console.log(` ↳ bonus_configs already restructured`); } } } // ── 7. Add todo fields to assigned_chores if missing ── const assignedCol = await getCollection("assigned_chores"); if (assignedCol) { let needsUpdate = false; // 7a. Make templateId non-required (todos don't use templates) const tplField = assignedCol.fields.find( (f: any) => f.name === "templateId", ); if (tplField && tplField.required) { console.log( "[migrate] Making assigned_chores.templateId non-required...", ); tplField.required = false; needsUpdate = true; } // 7b. Add isTodo, startDate, completeBy fields const hasIsTodo = assignedCol.fields.some((f: any) => f.name === "isTodo"); if (!hasIsTodo) { console.log("[migrate] Adding todo fields to assigned_chores..."); assignedCol.fields.push( { name: "isTodo", type: "bool" }, { name: "startDate", type: "text" }, { name: "completeBy", type: "text" }, ); needsUpdate = true; } else { console.log(` ↳ assigned_chores.isTodo already exists`); } if (needsUpdate) { await updateCollection(assignedCol.id, { name: "assigned_chores", type: "base", listRule: assignedCol.listRule, viewRule: assignedCol.viewRule, createRule: assignedCol.createRule, updateRule: assignedCol.updateRule, deleteRule: assignedCol.deleteRule, fields: assignedCol.fields, }); } } else { console.log( ` ↳ assigned_chores collection not found (will be created by seed)`, ); } // ── 8. Add completedAt timestamp to completions ── const complCol = await getCollection("completions"); if (complCol) { const hasCompletedAt = complCol.fields.some( (f: any) => f.name === "completedAt", ); if (!hasCompletedAt) { console.log("[migrate] Adding completions.completedAt..."); complCol.fields.push({ name: "completedAt", type: "date", required: false, hidden: false, }); await updateCollection(complCol.id, { name: "completions", type: "base", listRule: complCol.listRule, viewRule: complCol.viewRule, createRule: complCol.createRule, updateRule: complCol.updateRule, deleteRule: complCol.deleteRule, fields: complCol.fields, }); console.log(" ✓ completions.completedAt added"); } else { console.log(` ↳ completions.completedAt already exists`); } } else { console.log( ` ↳ completions collection not found (will be created by seed)`, ); } // ── 9. Create chat collections (messages + chat_typing) ── const famsColChat = await getCollection("fams"); if (famsColChat) { // 9a. messages const msgsCol = await getCollection("messages"); if (!msgsCol) { console.log("[migrate] Creating messages collection..."); await createCollection({ name: "messages", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "famId", type: "relation", required: true, collectionId: famsColChat.id, maxSelect: 1, cascadeDelete: true, }, { name: "authorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, { name: "authorId", type: "text", required: true }, { name: "authorName", type: "text", required: true }, { name: "authorColor", type: "text", required: false }, { name: "content", type: "text", required: true }, { name: "createdAt", type: "date", required: false }, { name: "clientId", type: "text", required: false, max: 64 }, ], }); } else { console.log(` ↳ messages already exists`); // PB 0.39 doesn't auto-add createdAt to API-created collections, and // older stores may predate it. Chat filters/sorts on createdAt, so // ensure the field exists even if the collection was created without it. const msgsFields = msgsCol.fields?.map((f: any) => f.name) || []; let msgsChanged = false; if (!msgsFields.includes("createdAt")) { console.log(" ↳ adding missing createdAt field to messages..."); msgsCol.fields.push({ name: "createdAt", type: "date", required: false, min: "", max: "", }); msgsChanged = true; } if (!msgsFields.includes("clientId")) { console.log(" ↳ adding missing clientId field to messages..."); msgsCol.fields.push({ name: "clientId", type: "text", required: false, max: 64 }); msgsChanged = true; } await updateCollection("messages", { listRule: "", viewRule: "", fields: msgsCol.fields, }); } // 9b. chat_typing (transient presence rows, one per actor) const typingCol = await getCollection("chat_typing"); if (!typingCol) { console.log("[migrate] Creating chat_typing collection..."); await createCollection({ name: "chat_typing", type: "base", listRule: "", viewRule: "", createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "famId", type: "relation", required: true, collectionId: famsColChat.id, maxSelect: 1, cascadeDelete: true, }, { name: "actorId", type: "text", required: true }, { name: "actorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, { name: "authorName", type: "text", required: true }, { name: "authorColor", type: "text", required: false }, { name: "typing", type: "bool", required: false }, ], }); } else { console.log(` ↳ chat_typing already exists`); await updateCollection("chat_typing", { listRule: "", viewRule: "" }); } } else { console.log(` ↳ fams collection not found — chat collections deferred`); } // ── 26. Add famId + role to the users auth collection ── // Admin identity now lives on the auth record so PB rules can scope via // @request.auth.famId. role defaults to "parent" (only admins log in for now; // children become a separate auth collection in the membership phase). { const usersCol = await getCollection("users"); if (usersCol) { const famsCol2 = await getCollection("fams"); const hasFamId = usersCol.fields.some((f: any) => f.name === "famId"); const hasRole = usersCol.fields.some((f: any) => f.name === "role"); if (!hasFamId || !hasRole) { console.log("[migrate] Adding famId/role to users collection..."); if (!hasFamId && famsCol2) { usersCol.fields.push({ name: "famId", type: "relation", required: false, collectionId: famsCol2.id, maxSelect: 1, cascadeDelete: false, }); } if (!hasRole) { usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1, }); } await updateCollection(usersCol.id, { name: "users", type: "auth", listRule: usersCol.listRule, viewRule: usersCol.viewRule, createRule: usersCol.createRule, updateRule: usersCol.updateRule, deleteRule: usersCol.deleteRule, fields: usersCol.fields, }); console.log(" ✓ users.famId/role added"); } else { console.log(" ↳ users.famId/role already present"); } } } // ── 27. Backfill users.famId from fam_admins ── { const usersCol = await getCollection("users"); if (usersCol) { const hasFamId = usersCol.fields.some((f: any) => f.name === "famId"); if (hasFamId) { try { const t = await auth(); const adminsRes = await fetch( `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { headers: { Authorization: `Bearer ${t}` } }, ); const adminsData = await adminsRes.json(); let count = 0; for (const a of adminsData?.items || []) { const u = await fetch( `${PB_ENDPOINT}/api/collections/users/records/${a.userId}`, { headers: { Authorization: `Bearer ${t}` } }, ); if (!u.ok) continue; const user = await u.json(); if (!user.famId) { await fetch( `${PB_ENDPOINT}/api/collections/users/records/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ famId: a.famId, role: "parent" }), }, ); count++; } } if (count > 0) console.log(` ✓ Backfilled users.famId/role for ${count} users`); else console.log(" ↳ users.famId already backfilled"); } catch (e) { console.log( " ↳ users.famId backfill skipped:", e instanceof Error ? e.message : e, ); } } } } // ── 28. Lock family-scoped WRITE rules to the caller's famId ── // Replaces the old "superuser-only writes via Hono" model. SvelteKit writes // as the authenticated user, so PB itself enforces famId scoping — no more // internet CRUD (anonymous `@request.auth` is null → rule fails). Reads stay // public until children become authenticated (membership phase). // // Admin-only collections additionally require role='parent'; child-accessible // collections (completions toggle, reward claim, chat) are famId-scoped only. { const PARENT_WRITE = "@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'"; const PARENT_SCOPED = "famId = @request.auth.famId && @request.auth.role = 'parent'"; const FAM_WRITE = "@request.body.famId = @request.auth.famId"; const FAM_SCOPED = "famId = @request.auth.famId"; const ADMIN_ONLY = [ "chore_templates", "assigned_chores", "bonus_templates", "bonus_configs", "settings", "weekly_history", "monthly_bonuses", "seasons", ]; const CHILD_ACCESSIBLE = ["completions", "rewards", "messages", "chat_typing"]; for (const name of [...ADMIN_ONLY, ...CHILD_ACCESSIBLE]) { const c = await getCollection(name); if (!c) continue; const isParent = ADMIN_ONLY.includes(name); const write = isParent ? PARENT_WRITE : FAM_WRITE; const scoped = isParent ? PARENT_SCOPED : FAM_SCOPED; if (c.createRule === write && c.updateRule === scoped && c.deleteRule === scoped) continue; await updateCollection(c.id, { name, type: c.type, listRule: c.listRule, viewRule: c.viewRule, createRule: write, updateRule: scoped, deleteRule: scoped, fields: c.fields, }); console.log(` ↳ Locked ${name} write rules (${isParent ? "parent" : "fam"} scoping)`); } } // ── 28b. Allow each user to READ + UPDATE their own fam record ── // fams is the root collection: its record id IS the famId, and it has no // famId field pointing to itself. So the scoping rule compares the record id // to the caller's famId. Reads are enabled so both parents and children can // load their fam via their own token; create/delete stay superuser-only. { const c = await getCollection("fams"); if (c) { const wantList = c.listRule !== "id = @request.auth.famId"; const wantView = c.viewRule !== "id = @request.auth.famId"; const wantUpdate = c.updateRule !== "id = @request.auth.famId"; if (wantList || wantView || wantUpdate) { await updateCollection(c.id, { name: "fams", type: c.type, listRule: "id = @request.auth.famId", viewRule: "id = @request.auth.famId", createRule: c.createRule, updateRule: "id = @request.auth.famId", deleteRule: c.deleteRule, fields: c.fields, }); console.log(" ↳ fams read+update scoped to own record (id = @request.auth.famId)"); } } } // ── 29. Add username identity to the users auth collection ── // Members now live in `users` alongside admins. They never type a password; // OTP is the gate. username is registered as a password-auth IDENTITY so the // server can authWithPassword(username, derivedPassword) at join time — the // password is derived from (MEMBER_SECRET + famSlug + username), never // user-supplied. email is made optional (admins log in via email; members use // username only and have no email). In PB v0.23+ an identity field must have // a single-column UNIQUE index AND be listed in passwordAuth.identityFields. { const usersCol = await getCollection("users"); if (usersCol) { const famsCol2 = await getCollection("fams"); const hasUsername = usersCol.fields.some((f: any) => f.name === "username"); // email: required → optional (members have no email) const emailField = usersCol.fields.find((f: any) => f.name === "email"); if (emailField && emailField.required) { emailField.required = false; } if (!hasUsername && famsCol2) { usersCol.fields.push({ name: "username", type: "text", required: true }); } // ensure a UNIQUE index on username exists (identity requirement) let indexes = usersCol.indexes || []; const hasUsernameIdx = indexes.some((i: string) => /username/i.test(i)); if (!hasUsernameIdx) { indexes = [ ...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''", ]; } // register username as a password-auth identity field const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] }; const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"]; if (!identityFields.includes("username")) identityFields.push("username"); await updateCollection(usersCol.id, { name: "users", type: "auth", listRule: usersCol.listRule, viewRule: usersCol.viewRule, createRule: usersCol.createRule, updateRule: usersCol.updateRule, deleteRule: usersCol.deleteRule, fields: usersCol.fields, indexes, passwordAuth: { enabled: true, identityFields }, }); console.log(" ✓ users: email optional, username auth identity"); } } // ── 30. otp: OTP store (superuser-only) ── // Holds the rotating one-time code and the OTP-issue timestamp used for the // 20-minute join window. Sensitive (OTPs) → not public; read/written via // createSuperClient in SvelteKit. `created`/`updated` are PB built-ins; the // manual `updatedAt` is written ONLY on OTP (re)issue so the window stays // accurate. userId links to the users auth record so each child's config is // uniquely addressable. (Display colour lives on users.color, not here.) { if (!(await getCollection("otp"))) { const famsCol = await getCollection("fams"); const usersCol = await getCollection("users"); if (!famsCol || !usersCol) throw new Error("fams/users collection not found"); console.log("[migrate] Creating otp collection..."); await createCollection({ name: "otp", type: "base", listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, fields: [ { name: "famId", type: "relation", required: true, collectionId: famsCol.id, maxSelect: 1, cascadeDelete: false, }, { name: "userId", type: "relation", required: true, collectionId: usersCol.id, maxSelect: 1, cascadeDelete: false, }, { name: "otp", type: "text", required: false }, { name: "updatedAt", type: "text", required: false }, ], }); } else { console.log(" ↳ otp already exists"); } } // ── 31. Add name + color to users (child display fields) ── // Children are now `users` records; admin views (weekly summary, ledger, // bonus progress, kanban) render name/color from the users record directly // instead of a separate members row. { const usersCol = await getCollection("users"); if (usersCol) { const needName = !usersCol.fields.some((f: any) => f.name === "name"); const needColor = !usersCol.fields.some((f: any) => f.name === "color"); if (needName || needColor) { console.log("[migrate] Adding name/color to users collection..."); if (needName) usersCol.fields.push({ name: "name", type: "text", required: false }); if (needColor) usersCol.fields.push({ name: "color", type: "text", required: false }); await updateCollection(usersCol.id, { name: "users", type: "auth", listRule: usersCol.listRule, viewRule: usersCol.viewRule, createRule: usersCol.createRule, updateRule: usersCol.updateRule, deleteRule: usersCol.deleteRule, fields: usersCol.fields, }); console.log(" ✓ users.name/color added"); // Backfill display fields for existing child users (created before the // name/color fields existed). try { const t = await auth(); const childRes = await fetch( `${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent( "role = 'child'", )}`, { headers: { Authorization: `Bearer ${t}` } }, ); const childData = await childRes.json(); for (const u of childData?.items || []) { if (!u.name || !u.color) { await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}`, }, body: JSON.stringify({ name: u.name || u.username || "", color: u.color || "#6366f1", }), }); } } if ((childData?.items || []).length) console.log(" ↳ Backfilled child users name/color"); } catch (e) { console.log( " ↳ child name/color backfill skipped:", e instanceof Error ? e.message : e, ); } } else { console.log(" ↳ users.name/color already present"); } } } // ── 31b. Backfill parent role on users ── // Legacy parent users were created before users.role existed (or before it // was set), leaving role empty. The app falls back `role || 'parent'`, but we // normalize it here so records are self-consistent. { const t = await auth(); const headers = { Authorization: `Bearer ${t}`, "Content-Type": "application/json", }; try { const res = await fetch( `${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent( "role = ''", )}`, { headers }, ); const data = await res.json(); for (const u of data?.items || []) { await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, { method: "PATCH", headers, body: JSON.stringify({ role: "parent" }), }); } if ((data?.items || []).length) console.log(` ↳ Backfilled ${data.items.length} users -> role=parent`); } catch (e) { console.log( " ↳ parent role backfill skipped:", e instanceof Error ? e.message : e, ); } } // ── 32. Repoint memberId relations from members -> users ── // Every child-scoped collection's memberId field now points at the users // auth collection (children live there as role='child'). Must run before // the members collection is deleted (a collection referenced by a relation // field cannot be removed). PB (v0.39) forbids changing a relation field's // target collection in place, so we drop the field and re-add it targeting // users in two separate collection updates. { const usersCol = await getCollection("users"); const membersCol = await getCollection("members"); if (usersCol && membersCol) { for (const name of [ "assigned_chores", "completions", "rewards", "weekly_history", "bonus_configs", ]) { const c = await getCollection(name); if (!c) continue; const f = c.fields.find((x: any) => x.name === "memberId"); if (f && f.collectionId === membersCol.id) { const base = { name, type: c.type, listRule: c.listRule, viewRule: c.viewRule, createRule: c.createRule, updateRule: c.updateRule, deleteRule: c.deleteRule, }; const without = c.fields.filter((x: any) => x.name !== "memberId"); // 1) drop memberId await updateCollection(c.id, { ...base, fields: without }); // 2) re-add memberId pointing at users const withUsers = without.concat({ name: "memberId", type: "relation", required: false, collectionId: usersCol.id, cascadeDelete: false, minSelect: 0, maxSelect: 1, }); await updateCollection(c.id, { ...base, fields: withUsers }); console.log(` ↳ Repointed ${name}.memberId -> users`); } else { console.log(` ↳ ${name}.memberId already -> users`); } } } else if (usersCol) { console.log(" ↳ members already gone; memberId rels unchanged"); } } // ── 32b. Scope users read/write rules for the child model ── // Children live in `users`. Family reads (admin famStore, kanban, loads) run // as the authenticated user via pbUser/pb.authStore, so the collection needs // list/view rules keyed to the caller's famId. Creating children stays // superuser-only (issueAccess/createChild use createSuperClient); parents may // update/delete their own fam's child users via pbUser. { const usersCol = await getCollection("users"); if (usersCol) { const listRule = "famId = @request.auth.famId"; const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'"; if ( usersCol.listRule !== listRule || usersCol.viewRule !== listRule || usersCol.updateRule !== parentWrite || usersCol.deleteRule !== parentWrite ) { console.log("[migrate] Scoping users read/write rules..."); await updateCollection(usersCol.id, { name: "users", type: "auth", listRule, viewRule: listRule, createRule: usersCol.createRule, updateRule: parentWrite, deleteRule: parentWrite, fields: usersCol.fields, }); console.log(" ↳ users rules: list/view = famId scope, parent write"); } else { console.log(" ↳ users rules already scoped"); } } } // ── 33. Delete legacy members collection + stale child-scoped data ── // Old member rows and the data keyed to them are not preserved (accounts // won't be reused). Wipe child-scoped rows whose memberId pointed at the old // members rows, clear bonus_configs member targets, then drop the collection. { const membersCol = await getCollection("members"); if (membersCol) { console.log("[migrate] Removing legacy members collection + stale data..."); const t = await auth(); const headers = { Authorization: `Bearer ${t}` }; const wipeCollection = async (name: string) => { let page = 0; for (;;) { const res = await fetch( `${PB_ENDPOINT}/api/collections/${name}/records?perPage=100&page=${page + 1}`, { headers }, ); const data = await res.json(); const items: any[] = data?.items || []; if (!items.length) break; for (const r of items) { await fetch( `${PB_ENDPOINT}/api/collections/${name}/records/${r.id}`, { method: "DELETE", headers }, ); } if (items.length < 100) break; page++; } }; for (const name of [ "assigned_chores", "completions", "rewards", "weekly_history", ]) { await wipeCollection(name); } console.log(" ↳ Wiped stale child-scoped data"); const cfgRes = await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=200`, { headers }, ); const cfgData = await cfgRes.json(); for (const cfg of cfgData?.items || []) { if (cfg.memberId) { await fetch( `${PB_ENDPOINT}/api/collections/bonus_configs/records/${cfg.id}`, { method: "PATCH", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ memberId: null }), }, ); } } console.log(" ↳ Cleared bonus_configs.memberId targets"); await fetch(`${PB_ENDPOINT}/api/collections/${membersCol.id}`, { method: "DELETE", headers, }); console.log(" ✓ members collection deleted"); } else { console.log(" ↳ members already removed"); } } // ── 34. Drop legacy fam_admins collection + unused fams.inviteCode ── // Parent identity now lives entirely on the `users` record (famId, role, // name, color, email); the join flow is OTP-based, so inviteCode is unused. { const adminsCol = await getCollection("fam_admins"); if (adminsCol) { const t = await auth(); const headers = { Authorization: `Bearer ${t}` }; await fetch(`${PB_ENDPOINT}/api/collections/${adminsCol.id}`, { method: "DELETE", headers, }); console.log(" ✓ fam_admins collection deleted"); } else { console.log(" ↳ fam_admins already removed"); } const famsCol = await getCollection("fams"); if (famsCol && famsCol.fields.some((f: any) => f.name === "inviteCode")) { famsCol.fields = famsCol.fields.filter( (f: any) => f.name !== "inviteCode", ); await updateCollection(famsCol.id, { name: "fams", type: "base", listRule: famsCol.listRule || "", viewRule: famsCol.viewRule || "", createRule: famsCol.createRule, updateRule: famsCol.updateRule, deleteRule: famsCol.deleteRule, fields: famsCol.fields, }); console.log(" ✓ fams.inviteCode field removed"); } } console.log("[migrate] Done"); }