From 6d05721629d25921c2fa86e3b1805369dabecda1 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Sun, 20 Sep 2026 16:40:09 +0100 Subject: [PATCH] payday tests --- frontend/src/lib/server/migrate.ts | 19 +++++++++++- .../src/lib/server/services/completions.ts | 18 +++++++++--- frontend/src/lib/server/services/fam.ts | 19 +++++++----- frontend/src/lib/server/services/rewards.ts | 29 +++++++++++++------ .../src/routes/[fam]/[username]/+page.svelte | 15 ++++++++-- shared/pb/schema.ts | 6 ++++ 6 files changed, 82 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index bd62f7d..a66e1d8 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -526,7 +526,7 @@ export async function migrate(): Promise { await ensureBonusFields(); await ensureTemplateFields(); await ensureAssignedChoreFields(); - await ensureAccessCodes(); + await ensureFamReadRules(); await ensureAccessCodes(); await ensurePlatform(); // Superuser-only collections also land on EXISTING installs (ensureSchema's // early return skips them). Like ensureOtp before it, ensurePins no-ops when @@ -540,6 +540,23 @@ export async function migrate(): Promise { console.log('[migrate] Done'); } +// Children toggle chores with their own token, so fam-scoped collections need +// fam read rules on EXISTING installs (fresh installs get them via +// SCHEMA_PLAN). Without these, the chore lookup returns empty and the +// completions create fails relation validation ("resource cannot be found"). +// Idempotent. +async function ensureFamReadRules(): Promise { + const readRule = 'famId = @request.auth.famId'; + for (const name of ['assigned_chores', 'completions', 'rewards']) { + const col = await getCollection(name); + if (!col) continue; + if (col.listRule !== readRule || col.viewRule !== readRule) { + await updateCollection(col.id, { ...col, listRule: readRule, viewRule: readRule }); + console.log(`[migrate] Fixed read rules on ${name}.`); + } + } +} + // 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 { diff --git a/frontend/src/lib/server/services/completions.ts b/frontend/src/lib/server/services/completions.ts index a33925c..a1f1aac 100644 --- a/frontend/src/lib/server/services/completions.ts +++ b/frontend/src/lib/server/services/completions.ts @@ -49,10 +49,20 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a const { assignedChoreId, date } = body; if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required'); - const choreList = await pb - .collection('assigned_chores') - .getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` }); - const chore = choreList?.[0]; + // Chore existence is checked as superuser so a stale client ID (parent + // re-saved the chores grid → old assigned rows deleted, new ids issued) + // surfaces as a clear "gone" error instead of PB's generic + // "resource cannot be found" relation-validation 400. The child's own + // token is still used for the completion writes below (PB rules enforce). + let chore: any; + try { + chore = await pbAdmin.getOne('assigned_chores', assignedChoreId); + } catch { + throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list'); + } + if (!chore || chore.famId !== famId) { + throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list'); + } const isTodo = chore?.isTodo; const isShared = chore?.shared === true || !chore?.memberId; // Only one-off shared todos are claimed (winner takes it). Recurring diff --git a/frontend/src/lib/server/services/fam.ts b/frontend/src/lib/server/services/fam.ts index 0a6cb07..044a5d1 100644 --- a/frontend/src/lib/server/services/fam.ts +++ b/frontend/src/lib/server/services/fam.ts @@ -167,13 +167,16 @@ export async function weeklySummary(pb: any, famId: string) { return sum + (chore?.type === 'money' ? Number(chore.value) : 0); }, 0); - let bonusMoney = 0; - try { - const cashRewards = await pb.collection('rewards').getFullList({ - filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` - }); - bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); - } catch {} + let bonusMoney = 0; + try { + // Incoming cash counts too (unclaimed/requested, e.g. achieved but + // not yet approved pocket money) — this is the "earned this week" + // figure, not just banked cash. + const cashRewards = await pb.collection('rewards').getFullList({ + filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'` + }); + bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); + } catch {} return { memberId: m.id, @@ -276,7 +279,7 @@ export async function completeWeek(pb: any, famId: string) { filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` }), pb.collection('rewards').getFullList({ - filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` + filter: `famId = '${famId}' && rewardType = 'cash' && date >= '${ws}'` }) ]); } catch {} diff --git a/frontend/src/lib/server/services/rewards.ts b/frontend/src/lib/server/services/rewards.ts index d13ce19..3c5f2ce 100644 --- a/frontend/src/lib/server/services/rewards.ts +++ b/frontend/src/lib/server/services/rewards.ts @@ -1,4 +1,4 @@ -import { todayInTz, resolveTz } from '@shared/timezone'; +import { todayInTz, resolveTz, wallClockToUtc } from '@shared/timezone'; import { famMeta } from './fam'; import { pbAdmin } from '$lib/server/pocketbase'; @@ -26,24 +26,35 @@ async function completeOnceConfig(famId: string, reward: any) { } catch {} } -export function assertPaydayUnlocked(reward: any, tz?: string) { +// Payday-gated rewards (claimable 'payday', e.g. pocket money) unlock on the +// settle DATE at the fam's configured payday TIME — not midnight. Anything +// else (claimable 'immediate'/unset, e.g. manual triggers) is always claimable. +export function assertPaydayUnlocked(reward: any, tz?: string, paydayTime?: string) { if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; - const today = todayInTz(resolveServerTz(tz)); - if (today < reward.settleDate) { - throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`); + const resolvedTz = resolveServerTz(tz); + const settle = (reward.settleDate as string).slice(0, 10); + const today = todayInTz(resolvedTz); + if (today < settle) { + throw new Error(`This bonus pays out on payday (${settle}) — hang tight!`); + } + if (today === settle && paydayTime && /^\d{2}:\d{2}$/.test(paydayTime)) { + const unlock = wallClockToUtc(settle, paydayTime, resolvedTz); + if (Date.now() < unlock) { + throw new Error(`This bonus unlocks at ${paydayTime} on payday — hang tight!`); + } } } // Member claim → status 'requested' (pending parent approval). export async function claim(pb: any, famId: string, id: string) { const now = new Date().toISOString(); - const { tz } = await famMeta(pb, famId); + const { tz, paydayTime } = await famMeta(pb, famId); const found = await pb .collection('rewards') .getFullList({ filter: `famId = '${famId}' && id = '${id}'` }); const reward = found?.[0]; if (!reward) throw new Error('Reward not found'); - assertPaydayUnlocked(reward, tz); + assertPaydayUnlocked(reward, tz, paydayTime); return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now }); } @@ -59,14 +70,14 @@ export async function approve(pb: any, famId: string, id: string) { export async function requestAll(pb: any, famId: string, memberId: string) { const now = new Date().toISOString(); - const { tz } = await famMeta(pb, famId); + const { tz, paydayTime } = await famMeta(pb, famId); const rewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'` }); let count = 0; for (const r of rewards) { try { - assertPaydayUnlocked(r, tz); + assertPaydayUnlocked(r, tz, paydayTime); } catch { continue; } diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index a05408b..11700be 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -540,9 +540,14 @@ (r.status === 'unclaimed' || r.status === 'requested') ) ); - // Payday-gated bonus rewards unlock on their settleDate (stamped server-side). + // Payday-gated bonus rewards unlock on their settleDate at the fam's payday + // time (stamped server-side) — not midnight on the day. function paydayLocked(r: any) { - return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate; + if (r.claimable !== 'payday' || !r.settleDate) return false; + const settle = (r.settleDate as string).slice(0, 10); + if (todayChild < settle) return true; + if (todayChild > settle) return false; + return nowMs < new Date(wallClockToUtc(settle, paydayTime, famTz)).getTime(); } let weekBonusTallies = $derived.by(() => { const map = new Map(); @@ -883,6 +888,12 @@ famStore.applyRecord('completions', optimistic, 'delete'); } } catch (e) { + // A stale assigned ID (parent re-saved chores while this tab was + // open in the background) leaves the chore untogglable — refresh + // the store so the UI self-heals instead of staying stuck. + try { + await famStore.init(famId); + } catch {} if (wasCompleted) { famStore.applyRecord( 'completions', diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index 05305e7..a2a42d5 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -388,6 +388,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED, + listRule: RULE_FAM_READ, + viewRule: RULE_FAM_READ, }, )(ids), }, @@ -418,6 +420,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, + listRule: RULE_FAM_READ, + viewRule: RULE_FAM_READ, }, )(ids), }, @@ -438,6 +442,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED, + listRule: RULE_FAM_READ, + viewRule: RULE_FAM_READ, }, )(ids), },