varierty of implementstions based on platform droplet issuance
This commit is contained in:
@@ -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 };
|
||||
@@ -53,15 +53,33 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
||||
.getFullList({ filter });
|
||||
if (existing?.length > 0) {
|
||||
await pb.collection('completions').delete(existing[0].id);
|
||||
// Un-completing a cash todo removes its one-time reward.
|
||||
if (isTodo && existing[0].rewardId) {
|
||||
await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {});
|
||||
}
|
||||
evaluateFam(pb, famId).catch(() => {});
|
||||
return { completed: false };
|
||||
}
|
||||
let rewardId: string | undefined;
|
||||
if (isTodo && chore?.type === 'money' && Number(chore.value) > 0) {
|
||||
const reward = await pb.collection('rewards').create({
|
||||
famId,
|
||||
memberId,
|
||||
label: chore.customName || 'Cash todo',
|
||||
value: Number(chore.value),
|
||||
rewardType: 'cash',
|
||||
status: 'unclaimed',
|
||||
date
|
||||
});
|
||||
rewardId = reward.id;
|
||||
}
|
||||
const record = await pb.collection('completions').create({
|
||||
famId,
|
||||
memberId,
|
||||
assignedChoreId,
|
||||
date,
|
||||
completedAt: new Date().toISOString()
|
||||
completedAt: new Date().toISOString(),
|
||||
...(rewardId ? { rewardId } : {})
|
||||
});
|
||||
evaluateFam(pb, famId).catch(() => {});
|
||||
return { completed: true, record };
|
||||
|
||||
@@ -6,10 +6,10 @@ const RESOURCES: Record<
|
||||
string,
|
||||
{ col: string; listFilter: (famId: string) => string; bonus?: 'config' | 'template' }
|
||||
> = {
|
||||
'chore-templates': { col: 'chore_templates', listFilter: (f) => `famId = '${f}'` },
|
||||
'chore-templates': { col: 'chore_templates', listFilter: (f) => `(famId = '${f}' || global = true)` },
|
||||
members: { col: 'users', listFilter: (f) => `famId = '${f}' && role = 'child'` },
|
||||
'assigned-chores': { col: 'assigned_chores', listFilter: (f) => `famId = '${f}'` },
|
||||
'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `famId = '${f}'`, bonus: 'template' },
|
||||
'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `(famId = '${f}' || global = true)`, bonus: 'template' },
|
||||
'bonus-configs': { col: 'bonus_configs', listFilter: (f) => `famId = '${f}'`, bonus: 'config' },
|
||||
completions: { col: 'completions', listFilter: (f) => `famId = '${f}'` },
|
||||
rewards: { col: 'rewards', listFilter: (f) => `famId = '${f}'` },
|
||||
|
||||
Reference in New Issue
Block a user