varierty of implementstions based on platform droplet issuance

This commit is contained in:
JCEEE
2026-08-26 10:17:32 +01:00
parent 7ebabc6e62
commit 8fea21704a
16 changed files with 1118 additions and 452 deletions
+196 -10
View File
@@ -322,15 +322,47 @@ async function ensureFamFields(): Promise<void> {
}
export async function migrate(): Promise<void> {
console.log("[migrate] Checking PB collection schemas...");
await ensureSchema();
// Runs even when the schema already exists (unlike ensureSchema's early
// return) so new platform collections/fields/seed land on existing installs.
await ensureFamFields();
await ensureBonusFields();
await ensureAccessCodes();
await ensurePlatform();
console.log("[migrate] Done");
console.log("[migrate] Checking PB collection schemas...");
await ensureSchema();
// Runs even when the schema already exists (unlike ensureSchema's early
// return) so new platform collections/fields/seed land on existing installs.
await ensureFamFields();
await ensureBonusFields();
await ensureTemplateFields();
await ensureAssignedChoreFields();
await ensureAccessCodes();
await ensurePlatform();
console.log("[migrate] Done");
}
// Add colour / icon / description to `assigned_chores` on existing installs so
// family-admins can override a template's look per assignment. Idempotent.
async function ensureAssignedChoreFields(): Promise<void> {
const col = await getCollection("assigned_chores");
if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
const needed: any[] = [];
if (!has("description")) needed.push({ name: "description", type: "text", required: false });
if (!has("icon")) needed.push({ name: "icon", type: "text", required: false });
if (!has("color")) needed.push({ name: "color", type: "text", required: false });
if (!has("emoji")) needed.push({ name: "emoji", type: "text", required: false });
// Todos can be celebration-only (emoji) as well as points/money.
let changed = !!needed.length;
const typeField = col.fields.find((f: any) => f.name === "type");
if (typeField && Array.isArray(typeField.values) && !typeField.values.includes("emoji")) {
typeField.values = [...typeField.values, "emoji"];
changed = true;
}
if (changed) {
await updateCollection(col.id, { ...col, fields: [...col.fields] });
}
const comp = await getCollection("completions");
if (comp && !comp.fields.some((f: any) => f.name === "rewardId")) {
await updateCollection(comp.id, {
...comp,
fields: [...comp.fields, { name: "rewardId", type: "text", required: false }],
});
}
}
// Add the pocket-money fields to bonus collections on existing installs, and
@@ -367,6 +399,21 @@ async function ensureBonusFields(): Promise<void> {
async function backfillPocketMoney(): Promise<void> {
const t = await auth();
// Flag legacy pocket-money configs that predate the isPocketMoney field so
// the dashboard recognises them (and we don't create duplicates below).
const legacyRes = await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=${encodeURIComponent(
"isPocketMoney!=true && name~'pocket' && thresholdType='percent'"
)}&perPage=500`,
{ headers: { Authorization: `Bearer ${t}` } }
);
for (const rec of (await legacyRes.json())?.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({ isPocketMoney: true }),
}).catch(() => {});
}
const childrenRes = await fetch(
`${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`,
{ headers: { Authorization: `Bearer ${t}` } }
@@ -399,4 +446,143 @@ async function backfillPocketMoney(): Promise<void> {
}).catch(() => {});
}
console.log("[migrate] Pocket-money droplets ensured.");
}
}
// Promote chore_templates / bonus_templates to platform-owned: add global/icon/
// color fields, make famId optional, and relax read rules so any family can see
// global (platform) templates as assignable droplets. Idempotent. Seeds a
// starter set of global templates on first run for out-of-the-box usage.
async function ensureTemplateFields(): Promise<void> {
const readRule = "global = true || famId = @request.auth.famId";
for (const name of ["chore_templates", "bonus_templates"]) {
const col = await getCollection(name);
if (!col) continue;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
let changed = false;
const fields = [...col.fields];
for (const f of [
{ name: "global", type: "bool", required: false },
{ name: "icon", type: "text", required: false },
{ name: "color", type: "text", required: false },
]) {
if (!has(f.name)) {
fields.push(f);
changed = true;
}
}
const famId = fields.find((f: any) => f.name === "famId");
if (famId && famId.required) {
famId.required = false;
changed = true;
}
if (col.listRule !== readRule || col.viewRule !== readRule) {
col.listRule = readRule;
col.viewRule = readRule;
changed = true;
}
if (changed) await updateCollection(col.id, { ...col, fields });
}
await seedGlobalTemplates();
}
const GLOBAL_TEMPLATE_ICONS = [
"Bed",
"Sparkles",
"BookOpen",
"Utensils",
"Wallet",
"Trophy",
"Star",
"Moon",
"Sun",
"Apple",
"Car",
"Gamepad2",
"Music",
"Shirt",
"Leaf",
"Heart",
"Gift",
"Timer",
];
async function seedGlobalTemplates(): Promise<void> {
const t = await auth();
const countGlobal = async (name: string) => {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
return ((await res.json())?.items?.length) || 0;
};
if ((await countGlobal("chore_templates")) > 0 || (await countGlobal("bonus_templates")) > 0) {
console.log("[migrate] Global templates already present — skipping seed.");
return;
}
const choreSeeds = [
{ name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 2, icon: "Bed", color: "#6366f1" },
{ name: "Tidy Room", defaultFrequency: "weekly", defaultType: "points", defaultValue: 10, icon: "Sparkles", color: "#8b5cf6" },
{ name: "Homework", defaultFrequency: "daily", defaultType: "points", defaultValue: 5, icon: "BookOpen", color: "#0ea5e9" },
{ name: "Set Table", defaultFrequency: "daily", defaultType: "money", defaultValue: 1, icon: "Utensils", color: "#10b981" },
];
const bonusSeeds = [
{
name: "Pocket Money",
target: "individual",
type: "threshold",
thresholdType: "percent",
occurrence: "recurring",
rewardType: "cash",
rewardValue: "",
criteriaValue: 50,
period: "weekly",
isPocketMoney: true,
icon: "Wallet",
color: "#22c55e",
},
{
name: "Reading Bonus",
target: "individual",
type: "count",
occurrence: "recurring",
rewardType: "points",
rewardValue: "50",
criteriaValue: 5,
period: "weekly",
icon: "BookOpen",
color: "#f59e0b",
},
{
name: "Star of the Week",
target: "competitive",
type: "threshold",
thresholdType: "points",
occurrence: "weekly",
rewardType: "prize",
rewardValue: "Treat",
criteriaValue: 100,
period: "weekly",
icon: "Trophy",
color: "#ef4444",
},
];
for (const s of choreSeeds) {
await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true }),
}).catch(() => {});
}
for (const s of bonusSeeds) {
await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true }),
}).catch(() => {});
}
console.log("[migrate] Seeded global templates.");
}
export { GLOBAL_TEMPLATE_ICONS };