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; let filter: string; if (isShared && !isTodo) { // Shared non-todo: any completion in the period = already claimed const { payday, tz } = await famMetaSU(famId); const { from, to } = periodWindow(chore?.frequency, payday, tz); filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`; } else 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 shared chore claimed by this member, un-share it if (isShared && 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 the shared chore by setting memberId if (isShared) { 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 }; }