import { SCHEMA_PLAN } from "@shared/pb/schema.ts"; import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; 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 = {}; 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 }, ], }); } else { console.log(` ↳ messages already exists`); await updateCollection("messages", { listRule: "", viewRule: "" }); } // 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`); } console.log("[migrate] Done"); }