payday tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user