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", "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("schedule"); if (needTargetUpdate || needPeriodUpdate) { console.log("[migrate] Updating bonus_configs fields..."); if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"]; if (needPeriodUpdate) periodField.values = ["schedule", "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. Add phase field to bonus_configs ── if (bonusConfigsCol) { const hasPhase = bonusConfigsCol.fields.some((f: any) => f.name === "phase"); if (!hasPhase) { console.log("[migrate] Adding phase field to bonus_configs..."); bonusConfigsCol.fields.push({ name: "phase", type: "select", required: true, values: ["template", "ready", "active", "completed"], maxSelect: 1, }); 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, }); // Backfill existing records → phase = "active" 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(); if (data?.items?.length) { for (const rec of data.items) { await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, body: JSON.stringify({ phase: "active" }), }); } console.log(` ✓ Backfilled ${data.items.length} bonus_configs → phase=active`); } } else { console.log(` ↳ bonus_configs.phase already exists`); } } // ── 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`); } } console.log("[migrate] Done"); }