1169 lines
37 KiB
TypeScript
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");
|
|
}
|