Files
famdone/proxy/src/migrate.ts
T
2026-07-31 16:13:05 +01:00

1169 lines
37 KiB
TypeScript

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<string> {
if (token) return token;
const res = await fetch(
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token;
return token!;
}
async function getCollection(name: string): Promise<any | null> {
const t = await auth();
const res = await fetch(
`${PB_ENDPOINT}/api/collections?filter=name='${name}'`,
{
headers: { Authorization: `Bearer ${t}` },
},
);
const data = await res.json();
return data?.items?.[0] || null;
}
async function createCollection(col: any): Promise<void> {
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<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify(col),
});
const data = await res.json();
if (!res.ok)
throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Updated collection: ${col.name || id}`);
}
export async function migrate(): Promise<void> {
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<string, any> = {};
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<string, any[]>();
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<string, string> = {};
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");
}