payday tests

This commit is contained in:
JCEEE
2026-09-20 16:40:09 +01:00
parent 3a6ba556e6
commit 6d05721629
6 changed files with 82 additions and 24 deletions
+18 -1
View File
@@ -526,7 +526,7 @@ export async function migrate(): Promise<void> {
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<void> {
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<void> {
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<void> {
@@ -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
+5 -2
View File
@@ -169,8 +169,11 @@ export async function weeklySummary(pb: any, famId: string) {
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' && status = 'claimed' && date >= '${ws}'`
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'`
});
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
} catch {}
@@ -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 {}
+20 -9
View File
@@ -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;
}
@@ -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<string, number>();
@@ -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',
+6
View File
@@ -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),
},