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 };
@@ -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 };
+2 -2
View File
@@ -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}'` },
+4 -4
View File
@@ -99,7 +99,7 @@ class FamStore {
pb.collection('users').getFullList({
filter: `famId = '${famId}' && role = 'child'`
}) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }) as Promise<
ChoreTemplate[]
>,
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<
@@ -111,7 +111,7 @@ class FamStore {
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<
BonusConfig[]
>,
pb.collection('bonus_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
pb.collection('bonus_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }) as Promise<
BonusTemplate[]
>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<
@@ -146,11 +146,11 @@ class FamStore {
private async subscribe() {
const subs: { collection: CollectionName; filter: string }[] = [
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
{ collection: 'chore_templates', filter: `famId = '${this.famId}'` },
{ collection: 'chore_templates', filter: `(famId = '${this.famId}' || global = true)` },
{ collection: 'assigned_chores', filter: `famId = '${this.famId}'` },
{ collection: 'completions', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_configs', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_templates', filter: `famId = '${this.famId}'` },
{ collection: 'bonus_templates', filter: `(famId = '${this.famId}' || global = true)` },
{ collection: 'rewards', filter: `famId = '${this.famId}'` },
{ collection: 'seasons', filter: `famId = '${this.famId}'` }
];
+10
View File
@@ -23,6 +23,9 @@ export interface BonusTemplate {
criteriaValue?: number;
period?: BonusPeriod;
isPocketMoney?: boolean;
global?: boolean;
icon?: string;
color?: string;
created: string;
updated: string;
}
@@ -71,6 +74,9 @@ export interface ChoreTemplate {
defaultFrequency: Frequency;
defaultType: RewardType;
defaultValue: number;
global?: boolean;
icon?: string;
color?: string;
created: string;
updated: string;
}
@@ -84,6 +90,10 @@ export interface AssignedChore {
type: RewardType;
value: number;
customName?: string;
description?: string;
icon?: string;
color?: string;
emoji?: string;
seasonIds?: string[];
isTodo?: boolean;
startDate?: string;