import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; 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}`); } 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}`); } export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); // ── 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)`); } // ── 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`); } } // ── 4. Create notifications collection if missing ── const notifCol = await getCollection("notifications"); if (!notifCol) { const famsCol = await getCollection("fams"); if (!famsCol) throw new Error("fams collection not found"); const membersCol = await getCollection("members"); if (!membersCol) throw new Error("members collection not found"); console.log("[migrate] Creating notifications collection..."); await createCollection({ name: "notifications", 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: "memberId", type: "relation", required: true, collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false, }, { name: "message", type: "text", required: true }, { name: "read", type: "bool", required: false }, ], }); } else { console.log(` ↳ notifications 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`); } } // ── 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`); } } } console.log("[migrate] Done"); }