Files
famdone/frontend/src/lib/server/services/completions.ts
T
2026-09-14 21:29:10 +01:00

134 lines
5.2 KiB
TypeScript

import { periodWindow } from '@shared/timezone';
import { pbAdmin } from '$lib/server/pocketbase';
import { evaluateFam } from './bonuses';
// Reads/writes the acting user may not be permitted by PB rules:
// - `fams` view (prod may predate RULE_OWN_FAM; migrate only bootstraps fresh)
// - `assigned_chores` update (parent-only rule, but children claim shared chores)
// Both stay fam-scoped: the fam record id and the chore's own famId are checked.
async function famMetaSU(famId: string) {
const fam: any = await pbAdmin.getOne('fams', famId);
const { resolveTz } = await import('@shared/timezone');
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveTz(fam.timezone || 'auto')
};
}
async function claimChore(assignedChoreId: string, famId: string, memberId: string | '') {
const chore: any = await pbAdmin.getOne('assigned_chores', assignedChoreId);
if (!chore || chore.famId !== famId) throw new Error('Chore not found');
await pbAdmin.update('assigned_chores', assignedChoreId, { memberId });
}
export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
}),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
]);
const { payday, paydayTime, tz } = await famMetaSU(famId);
return {
templates,
assigned,
completions,
rewards,
bonusConfigs,
payday,
paydayTime,
timezone: tz
};
}
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) {
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];
const isTodo = chore?.isTodo;
const isShared = chore?.shared === true || !chore?.memberId;
// Only one-off shared todos are claimed (winner takes it). Recurring
// shared chores stay unassigned so every member completes independently
// each period — claiming them permanently hides the chore from siblings.
const isClaimable = isShared && !!isTodo;
let filter: string;
if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else {
const { payday, tz } = await famMetaSU(famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
}
const existing = await pb
.collection('completions')
.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(() => {});
}
// If this was a claimed one-off todo, release it back to shared.
if (isClaimable && existing[0].memberId === memberId) {
await claimChore(assignedChoreId, famId, '');
}
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',
claimable: 'payday',
date
});
rewardId = reward.id;
}
const record = await pb.collection('completions').create({
famId,
memberId,
assignedChoreId,
date,
completedAt: new Date().toISOString(),
...(rewardId ? { rewardId } : {})
});
// Claim a shared one-off todo by setting memberId (recurring shared
// chores stay unassigned — see isClaimable above).
if (isClaimable) {
await claimChore(assignedChoreId, famId, memberId);
}
evaluateFam(pb, famId).catch(() => {});
return { completed: true, record };
}
export async function revoke(pb: any, famId: string, completionId: string) {
const completions = await pb
.collection('completions')
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
if (!completions?.length) throw new Error('Completion not found');
const completion = completions[0];
await pb.collection('completions').delete(completionId);
// If this was a shared chore, clear memberId to make it available again
const choreList = await pb.collection('assigned_chores')
.getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` });
const chore = choreList?.[0];
if (chore?.shared === true) {
await claimChore(chore.id, famId, '');
}
evaluateFam(pb, famId).catch(() => {});
return { revoked: true };
}