added todos | payday time settings | refactor payment scheduling

This commit is contained in:
JCEEE
2026-08-04 08:34:14 +01:00
parent ccbbd302d4
commit 85ea97ae96
16 changed files with 2877 additions and 659 deletions
+476 -43
View File
@@ -3,6 +3,13 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { pb } from "./pb.ts";
import { migrate } from "./migrate.ts";
import {
weekStart as tzWeekStart,
addDaysStr,
todayInTz,
wallClockToUtc,
resolveTz,
} from "../../timezone.ts";
const app = new Hono();
@@ -15,12 +22,16 @@ function handleError(c: any, err: unknown) {
// ── Helpers ─────────────────────────────────────────────
function weekStart(payday: number = 1): string {
const d = new Date();
const day = d.getDay();
const diff = d.getDate() - ((day - payday + 7) % 7);
d.setDate(diff);
return d.toISOString().slice(0, 10);
function resolveServerTz(tz?: string): string {
return resolveTz(tz || "auto");
}
function weekStart(payday: number = 1, tz?: string): string {
return tzWeekStart(payday, resolveServerTz(tz));
}
function addDays(dateStr: string, days: number): string {
return addDaysStr(dateStr, days);
}
function monthStart(month?: string): string {
@@ -39,19 +50,17 @@ function monthEnd(month?: string): string {
return `${month}-${String(lastDay).padStart(2, "0")}`;
}
function periodStart(period: string, payday?: number): string {
if (period === "daily") return new Date().toISOString().slice(0, 10);
if (period === "weekly") return weekStart(payday);
function periodStart(period: string, payday?: number, tz?: string): string {
if (period === "daily") return todayInTz(resolveServerTz(tz));
if (period === "weekly") return weekStart(payday, tz);
if (period === "monthly") return monthStart();
return weekStart(payday);
return weekStart(payday, tz);
}
function periodEnd(period: string, start: string): string {
if (period === "daily") return start;
if (period === "weekly") {
const d = new Date(start);
d.setDate(d.getDate() + 6);
return d.toISOString().slice(0, 10);
return addDaysStr(start, 6);
}
if (period === "monthly") {
const m = start.slice(0, 7);
@@ -70,6 +79,26 @@ async function getFamPayday(famId: string): Promise<number> {
}
}
async function getFamPaydayTime(famId: string): Promise<string> {
try {
const fam = await pb.getList("fams", `id = '${famId}'`);
const t = fam.items?.[0]?.paydayTime;
return t || "18:00";
} catch {
return "18:00";
}
}
async function getFamTimezone(famId: string): Promise<string> {
try {
const fam = await pb.getList("fams", `id = '${famId}'`);
const tz = fam.items?.[0]?.timezone;
return tz || "auto";
} catch {
return "auto";
}
}
// ── Middleware ─────────────────────────────────────────────
async function requireAdmin(c: any, next: any) {
@@ -129,6 +158,7 @@ app.post("/api/admin/signup", async (c) => {
slug,
inviteCode,
featureFlags: {},
timezone: "auto",
});
const adminName = parentName || email.split("@")[0];
const admin = await pb.create("fam_admins", {
@@ -515,6 +545,8 @@ app.get("/api/admin/:famId/fam", requireAdmin, async (c) => {
slug: fam.slug,
inviteCode: fam.inviteCode,
payday: fam.payday,
paydayTime: fam.paydayTime || "18:00",
timezone: fam.timezone || "auto",
});
} catch (err) {
return handleError(c, err);
@@ -539,12 +571,32 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
payday: record.payday,
});
}
if (body.payday !== undefined) {
const payday = Number(body.payday);
if (payday < 0 || payday > 6 || !Number.isInteger(payday))
return c.json({ error: "payday must be 0-6" }, 400);
const record = await pb.update("fams", famId, { payday });
return c.json({ payday: record.payday });
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
const patch: Record<string, string | number> = {};
if (body.payday !== undefined) {
const payday = Number(body.payday);
if (payday < 0 || payday > 6 || !Number.isInteger(payday))
return c.json({ error: "payday must be 0-6" }, 400);
patch.payday = payday;
}
if (body.paydayTime !== undefined) {
const paydayTime = String(body.paydayTime);
if (!/^\d{2}:\d{2}$/.test(paydayTime))
return c.json({ error: "paydayTime must be HH:MM" }, 400);
patch.paydayTime = paydayTime;
}
if (body.timezone !== undefined) {
const timezone = String(body.timezone);
if (timezone !== "auto" && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
return c.json({ error: "timezone must be an IANA name or 'auto'" }, 400);
patch.timezone = timezone;
}
const record = await pb.update("fams", famId, patch);
return c.json({
payday: record.payday,
paydayTime: record.paydayTime,
timezone: record.timezone,
});
}
return c.json({ error: "no valid fields" }, 400);
} catch (err) {
@@ -576,7 +628,8 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const payday = await getFamPayday(famId);
const ws = weekStart(payday);
const tz = await getFamTimezone(famId);
const ws = weekStart(payday, tz);
const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
@@ -587,7 +640,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
try {
const pointRewards = await pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
`famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`,
);
rewardPointsList = pointRewards.items;
} catch {} // schema may not have rewardType/status yet
@@ -649,12 +702,12 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
return sum + (chore?.type === "money" ? Number(chore.value) : 0);
}, 0);
// Include claimed cash rewards in money earned
// Include claimed cash rewards in money earned (this week only)
let bonusMoney = 0;
try {
const cashRewards = await pb.getList(
"rewards",
`famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed'`,
`famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`,
);
bonusMoney = cashRewards.items.reduce(
(sum: number, r: any) => sum + Number(r.value),
@@ -682,6 +735,229 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
}
});
// ── Admin: Family settings (debug flag) ────────────────
async function getFamSettings(famId: string): Promise<any> {
try {
return (
(
await pb.getList("settings", `famId = '${famId}'`)
).items?.[0] || {}
);
} catch {
return {};
}
}
app.get("/api/admin/:famId/settings", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const s = await getFamSettings(famId);
return c.json({
simulateEow: !!s.simulateEow,
webhookUrl: s.webhookUrl || "",
});
} catch (err) {
return handleError(c, err);
}
});
app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const body = await c.req.json();
let s = await getFamSettings(famId);
const patch: Record<string, unknown> = {};
if (body.simulateEow !== undefined) patch.simulateEow = !!body.simulateEow;
if (body.webhookUrl !== undefined)
patch.webhookUrl = String(body.webhookUrl);
if (!s.id) {
s = await pb.create("settings", { famId, ...patch });
} else if (Object.keys(patch).length) {
s = await pb.update("settings", s.id, patch);
}
return c.json({ simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || "" });
} catch (err) {
return handleError(c, err);
}
});
// ── Admin: EOW rollover preview (READ-ONLY) ──────────
app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const payday = await getFamPayday(famId);
const tz = await getFamTimezone(famId);
const ws = weekStart(payday, tz);
const we = periodEnd("weekly", ws);
const [members, assigned, completions, configs, rewards] =
await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
pb
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
.catch(() => ({ items: [] })),
pb.getList("rewards", `famId = '${famId}'`).catch(() => ({ items: [] })),
]);
let rewardPointsList: any[] = [];
let rewardCashList: any[] = [];
try {
const [pw, cw] = await Promise.all([
pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
),
pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`,
),
]);
rewardPointsList = pw.items;
rewardCashList = cw.items;
} catch {}
const assignedList = assigned.items;
const completionsList = completions.items;
const summaries = members.items.map((m: any) => {
const mc = completionsList.filter((c: any) => c.memberId === m.id);
const weekPoints = mc.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
}, 0);
const weekMoney = mc.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === "money" ? Number(ch.value) : 0);
}, 0);
const bonusPoints = rewardPointsList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const bonusMoney = rewardCashList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
return {
memberId: m.id,
memberName: m.name || m.username || m.id.slice(0, 6),
memberColor: m.color,
pointsEarned: weekPoints + bonusPoints,
moneyEarned: weekMoney + bonusMoney,
choresCompleted: mc.length,
bonusEarned: bonusPoints,
};
});
// Predicted auto-created rewards (dry-run, never writes)
const predictedRewards: any[] = [];
const existingRewards = rewards.items || [];
for (const cfg of configs.items || []) {
if (cfg.type === "manual") continue;
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : "";
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : "";
const periodCompletions = cfg.period
? completionsList.filter(
(c: any) =>
(c.date || "").slice(0, 10) >= pStart &&
(c.date || "").slice(0, 10) <= pEnd,
)
: completionsList;
const cfgRewards = existingRewards.filter(
(r: any) => r.bonusConfigId === cfg.id,
);
const tryEval = (target: any, sourceComps: any[]) => {
let current = 0;
if (cfg.type === "threshold")
current = sourceComps.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
}, 0);
else if (cfg.type === "count") current = sourceComps.length;
return current;
};
if (cfg.target === "individual") {
const targets = cfg.memberId
? members.items.filter((m: any) => m.id === cfg.memberId)
: members.items;
for (const m of targets) {
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
const current = tryEval(m, periodCompletions.filter((c: any) => c.memberId === m.id));
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
memberName: m.name || m.id.slice(0, 6),
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`,
});
}
} else if (cfg.target === "collaborative") {
if (cfgRewards.length) continue;
const allIds = members.items.map((m: any) => m.id);
const teamComps = periodCompletions.filter((c: any) =>
allIds.includes(c.memberId),
);
const current = tryEval(cfg, teamComps);
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
memberName: "Everyone",
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`,
});
} else if (cfg.target === "competitive") {
if (cfgRewards.length) continue;
const scored: { memberId: string; name: any; current: number }[] =
members.items.map((m: any) => ({
memberId: m.id,
name: m.name,
current: tryEval(
cfg,
periodCompletions.filter((c: any) => c.memberId === m.id),
),
}));
const qualified = scored.filter(
(st) => st.current >= Number(cfg.criteriaValue),
);
const eligible = qualified.length
? qualified
: scored.filter((st) => st.current > 0);
const winner = eligible.sort(
(aa, bb) => bb.current - aa.current,
)[0];
if (winner)
predictedRewards.push({
config: cfg.name,
memberName: winner.name,
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `winner ${winner.current} pts`,
});
}
}
// All completions would reset on rollover (next week)
const nextWeekStart = addDays(ws, 7);
return c.json({
simulateEow: true,
weekStart: ws,
weekEnd: we,
nextWeekStart,
summaries,
predictedRewards,
completionsThisWeek: completionsList.length,
});
} catch (err) {
return handleError(c, err);
}
});
// ── Admin CRUD: Bonus Templates ────────────────────────
app.get("/api/admin/:famId/bonus-templates", requireAdmin, async (c) => {
@@ -833,6 +1109,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const payday = await getFamPayday(famId);
const tz = await getFamTimezone(famId);
let configsData: any = { items: [] };
try {
configsData = await pb.getList(
@@ -859,7 +1136,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
const cfgRewards = allRewards.items.filter(
(r: any) => r.bonusConfigId === cfg.id,
);
const pStart = cfg.period ? periodStart(cfg.period, payday) : "";
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : "";
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : "";
const periodCompletions = cfg.period
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
@@ -977,9 +1254,10 @@ async function evaluateFam(famId: string): Promise<void> {
allRewards = await pb.getList("rewards", `famId = '${famId}'`);
} catch {}
const paydayEval = await getFamPayday(famId);
const tzEval = await getFamTimezone(famId);
for (const cfg of configs) {
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval) : "";
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : "";
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
const periodCompletions = cfg.period
? allCompletions.items.filter(
@@ -999,25 +1277,40 @@ async function evaluateFam(famId: string): Promise<void> {
? allMembers.items.filter((m: any) => m.id === cfg.memberId)
: allMembers.items;
for (const m of targetMembers) {
if (existingRewards.find((r: any) => r.memberId === m.id)) continue;
const memberCompletions = periodCompletions.filter(
(c: any) => c.memberId === m.id,
);
let achieved = false;
let current = 0;
if (cfg.type === "threshold") {
const total = memberCompletions.reduce((sum: number, c: any) => {
current = memberCompletions.reduce((sum: number, c: any) => {
const chore = allAssigned.items.find(
(a: any) => a.id === c.assignedChoreId,
);
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
}, 0);
achieved =
cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
} else if (cfg.type === "count") {
achieved =
cfg.criteriaValue > 0 &&
memberCompletions.length >= Number(cfg.criteriaValue);
current = memberCompletions.length;
}
const achieved =
cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
const memberReward = existingRewards.find(
(r: any) => r.memberId === m.id,
);
// Clean up reward if threshold is no longer met (e.g. after revoke)
if (memberReward && !achieved) {
if (memberReward.status !== "claimed") {
try {
await pb.delete("rewards", memberReward.id);
} catch {}
}
continue;
}
// Skip if already rewarded and still achieved
if (memberReward) continue;
if (achieved) {
const label =
cfg.rewardType === "cash"
@@ -1039,7 +1332,6 @@ async function evaluateFam(famId: string): Promise<void> {
}
}
} else if (cfg.target === "collaborative") {
if (existingRewards.length > 0) continue;
const allMemberIds = allMembers.items.map((m: any) => m.id);
const teamCompletions = periodCompletions.filter((c: any) =>
allMemberIds.includes(c.memberId),
@@ -1055,7 +1347,20 @@ async function evaluateFam(famId: string): Promise<void> {
} else if (cfg.type === "count") {
total = teamCompletions.length;
}
if (cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue)) {
const achieved =
cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
// Clean up collaborative rewards if threshold is no longer met
if (!achieved && existingRewards.length > 0) {
for (const r of existingRewards) {
if (r.status !== "claimed") {
try { await pb.delete("rewards", r.id); } catch {}
}
}
continue;
}
if (achieved && existingRewards.length === 0) {
for (const m of allMembers.items) {
const label =
cfg.rewardType === "cash"
@@ -1100,7 +1405,20 @@ async function evaluateFam(famId: string): Promise<void> {
const eligible =
qualified.length > 0 ? qualified : scored.filter((s) => s.current > 0);
const winner = eligible.sort((a, b) => b.current - a.current)[0];
if (winner && !existingRewards.length) {
// Clean up competitive reward if no winner or winner changed
if (existingRewards.length > 0) {
const existing = existingRewards[0];
const stillValid =
winner &&
existing.memberId === winner.memberId &&
winner.current > 0;
if (!stillValid && existing.status !== "claimed") {
try { await pb.delete("rewards", existing.id); } catch {}
}
}
if (winner && existingRewards.length === 0) {
const label =
cfg.rewardType === "cash"
? `${cfg.name} £${Number(cfg.rewardValue).toFixed(2)}`
@@ -1139,6 +1457,7 @@ app.get("/api/admin/:famId/bonus-configs/tallies", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const payday = await getFamPayday(famId);
const tz = await getFamTimezone(famId);
const configs = await pb.getList("bonus_configs", `famId = '${famId}'`);
const rewards = await pb.getList("rewards", `famId = '${famId}'`);
@@ -1150,7 +1469,7 @@ app.get("/api/admin/:famId/bonus-configs/tallies", requireAdmin, async (c) => {
let allTime = cfgRewards.length;
let thisPeriod = allTime;
if (cfg.period) {
const pStart = periodStart(cfg.period, payday);
const pStart = periodStart(cfg.period, payday, tz);
const pEnd = periodEnd(cfg.period, pStart);
thisPeriod = cfgRewards.filter(
(r: any) => r.date >= pStart && r.date <= pEnd,
@@ -1223,7 +1542,8 @@ app.post(
}
if (cfg.occurrence === "recurring" && cfg.period) {
const payday = await getFamPayday(famId);
const pStart = periodStart(cfg.period, payday);
const tz = await getFamTimezone(famId);
const pStart = periodStart(cfg.period, payday, tz);
const pEnd = periodEnd(cfg.period, pStart);
const periodRewards = memberRewards.filter(
(r: any) => r.date >= pStart && r.date <= pEnd,
@@ -1368,6 +1688,8 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
pb.getList("bonus_configs", `famId = '${famId}' && status = 'active'`),
]);
const payday = await getFamPayday(famId);
const paydayTime = await getFamPaydayTime(famId);
const timezone = await getFamTimezone(famId);
const rewardsList = rewards.items;
const configsList = bonusConfigs.items;
return c.json({
@@ -1376,6 +1698,9 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
completions: completions.items,
rewards: rewardsList,
bonusConfigs: configsList,
payday,
paydayTime,
timezone,
});
} catch (err) {
return handleError(c, err);
@@ -1471,10 +1796,10 @@ app.post("/api/admin/:famId/rewards/issue-all", requireAdmin, async (c) => {
const { memberId, message } = body;
if (!memberId) return c.json({ error: "memberId required" }, 400);
const now = new Date().toISOString();
// Find all requested rewards for this member
// Find all claimable rewards for this member (unclaimed or requested)
const rewards = await pb.getList(
"rewards",
`famId = '${famId}' && memberId = '${memberId}' && status = 'requested'`,
`famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`,
);
let count = 0;
for (const r of rewards.items) {
@@ -1689,13 +2014,121 @@ app.post(
},
);
// ── Payday: release this period's unpaid cash as an auto-claimed ask ──
async function releaseWeek(famId: string) {
const payday = await getFamPayday(famId);
const fams = await pb.getList("fams", `id = '${famId}'`);
const fam = fams.items?.[0];
if (!fam) throw new Error("Fam not found");
const tz = resolveTz(fam.timezone || "auto");
const wsToday = weekStart(payday, tz); // anchor date for the current payday period
// Payday fires at the configured time on the payday day. Before that moment
// (on the payday day itself) we hold off so the countdown can play out.
const paydayTime = fam.paydayTime || "18:00";
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
if (Date.now() < target.getTime()) {
return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() };
}
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
const members = await pb.getList("members", `famId = '${famId}'`);
let cashRewards: any = { items: [] };
try {
cashRewards = await pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`,
);
} catch {}
const breakdown: any[] = [];
const now = new Date().toISOString();
const today = now.slice(0, 10);
for (const m of members.items) {
const unpaid = (cashRewards.items || []).filter(
(r: any) => r.memberId === m.id && r.status !== "claimed",
);
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
if (total > 0) {
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
// the parent's Issue list, but keep them as individual rows (Issue All totals).
for (const r of unpaid) {
if (r.status !== "requested") {
await pb.update("rewards", r.id, {
status: "requested",
claimedAt: null,
date: (r.date || "").slice(0, 10) || today,
});
}
}
// Kid notice: the fun moment.
try {
await pb.create("notifications", {
famId,
memberId: m.id,
message: `You've earned £${total.toFixed(2)}! Go get it from your parent ⭐`,
read: false,
});
} catch {}
breakdown.push({
memberId: m.id,
name: m.name,
total,
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })),
});
}
}
await pb.update("fams", famId, { lastIssued: wsToday });
return { settled: true, weekStart: wsToday, breakdown };
}
async function authorizeFamReq(c: any): Promise<string | null> {
const sessFam = c.req.header("x-session-famid");
const sessUser = c.req.header("x-session-userid");
if (sessFam && sessUser) {
const admins = await pb.getList(
"fam_admins",
`famId = '${sessFam}' && userId = '${sessUser}'`,
);
if (admins.items?.length) return sessFam;
}
const devToken = c.req.header("x-device-token");
const devFam = c.req.header("x-device-famid");
if (devFam && devToken) {
const hashHex = crypto.createHash("sha256").update(devToken).digest("hex");
const members = await pb.getList(
"members",
`famId = '${devFam}' && deviceToken = '${hashHex}'`,
);
if (members.items?.length) return devFam;
}
return null;
}
app.post("/api/fam/:famId/payday", async (c) => {
try {
const famId = await authorizeFamReq(c);
if (!famId) return c.json({ error: "Unauthorized" }, 401);
const result = await releaseWeek(famId);
return c.json(result);
} catch (err) {
return handleError(c, err);
}
});
// ── Admin: Complete week (snapshot to weekly_history) ────
app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const payday = await getFamPayday(famId);
const ws = weekStart(payday);
const tz = await getFamTimezone(famId);
const ws = weekStart(payday, tz);
// Evaluate weekly bonus configs first
await evaluateFam(famId);
@@ -1713,11 +2146,11 @@ app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
const [pointsRewards, cashRewards] = await Promise.all([
pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
`famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`,
),
pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`,
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`,
),
]);
rewardPointsList = pointsRewards.items;
+178 -1
View File
@@ -311,6 +311,27 @@ export async function migrate(): Promise<void> {
} else {
console.log(` ↳ settings schema is current`);
}
// Ensure simulateEow debug flag exists (read-only EOW preview toggle)
const simField = settingsCol.fields.some(
(f: any) => f.name === "simulateEow",
);
if (!simField) {
console.log("[migrate] Adding settings.simulateEow (debug toggle)...");
settingsCol.fields.push({ name: "simulateEow", type: "bool" });
await updateCollection(settingsCol.id, {
name: "settings",
type: "base",
listRule: settingsCol.listRule,
viewRule: settingsCol.viewRule,
createRule: settingsCol.createRule,
updateRule: settingsCol.updateRule,
deleteRule: settingsCol.deleteRule,
fields: settingsCol.fields,
});
} else {
console.log(` ↳ settings.simulateEow already exists`);
}
}
// ── 4. Create notifications collection if missing ──
@@ -383,6 +404,119 @@ export async function migrate(): Promise<void> {
}
}
// ── 5b. Add lastIssued (payday heartbeat) field to fams if missing ──
{
const fc = famsCol || (await getCollection("fams"));
if (fc) {
const hasLastIssued = fc.fields.some((f: any) => f.name === "lastIssued");
if (!hasLastIssued) {
console.log("[migrate] Adding lastIssued field to fams...");
fc.fields.push({ name: "lastIssued", type: "text" });
await updateCollection(fc.id, {
name: "fams",
type: "base",
listRule: fc.listRule,
viewRule: fc.viewRule,
createRule: fc.createRule,
updateRule: fc.updateRule,
deleteRule: fc.deleteRule,
fields: fc.fields,
});
} else {
console.log(` ↳ fams.lastIssued already exists`);
}
}
}
// ── 5c. Add paydayTime (HH:MM) field to fams if missing ──
{
const fc = famsCol || (await getCollection("fams"));
if (fc) {
const hasPaydayTime = fc.fields.some((f: any) => f.name === "paydayTime");
if (!hasPaydayTime) {
console.log("[migrate] Adding paydayTime field to fams...");
fc.fields.push({ name: "paydayTime", type: "text" });
await updateCollection(fc.id, {
name: "fams",
type: "base",
listRule: fc.listRule,
viewRule: fc.viewRule,
createRule: fc.createRule,
updateRule: fc.updateRule,
deleteRule: fc.deleteRule,
fields: fc.fields,
});
} else {
console.log(` ↳ fams.paydayTime already exists`);
}
}
}
// ── 5d. Add timezone (IANA name or "auto") field to fams if missing ──
{
const fc = famsCol || (await getCollection("fams"));
if (fc) {
const hasTz = fc.fields.some((f: any) => f.name === "timezone");
if (!hasTz) {
console.log("[migrate] Adding timezone field to fams...");
fc.fields.push({
name: "timezone",
type: "text",
max: 64,
pattern: "",
autogeneratePattern: "",
primaryKey: false,
system: false,
required: false,
unique: false,
hidden: false,
presentable: false,
noDecimal: false,
});
await updateCollection(fc.id, {
name: "fams",
type: "base",
listRule: fc.listRule,
viewRule: fc.viewRule,
createRule: fc.createRule,
updateRule: fc.updateRule,
deleteRule: fc.deleteRule,
fields: fc.fields,
});
} else {
console.log(` ↳ fams.timezone already exists`);
}
}
}
// ── 5e. Backfill fams.timezone = "auto" for any existing fams ──
try {
const t = await auth();
const res = await fetch(
`${PB_ENDPOINT}/api/collections/fams/records?perPage=200&filter=${encodeURIComponent(
`timezone = "" || timezone = null`,
)}`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const data = await res.json();
const fams = data?.items || [];
for (const f of fams) {
await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${f.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ timezone: "auto" }),
});
}
if (fams.length) {
console.log(` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`);
}
} catch (err) {
console.log(" ↳ timezone backfill skipped:", err instanceof Error ? err.message : err);
}
// ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ──
// PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date`
// and just normalise existing records. The frontend reads with .slice(0, 10) either way.
@@ -1164,5 +1298,48 @@ export async function migrate(): Promise<void> {
}
}
console.log("[migrate] Done");
// ── 7. Add todo fields to assigned_chores if missing ──
const assignedCol = await getCollection("assigned_chores");
if (assignedCol) {
let needsUpdate = false;
// 7a. Make templateId non-required (todos don't use templates)
const tplField = assignedCol.fields.find((f: any) => f.name === "templateId");
if (tplField && tplField.required) {
console.log("[migrate] Making assigned_chores.templateId non-required...");
tplField.required = false;
needsUpdate = true;
}
// 7b. Add isTodo, startDate, completeBy fields
const hasIsTodo = assignedCol.fields.some((f: any) => f.name === "isTodo");
if (!hasIsTodo) {
console.log("[migrate] Adding todo fields to assigned_chores...");
assignedCol.fields.push(
{ name: "isTodo", type: "bool" },
{ name: "startDate", type: "text" },
{ name: "completeBy", type: "text" }
);
needsUpdate = true;
} else {
console.log(` ↳ assigned_chores.isTodo already exists`);
}
if (needsUpdate) {
await updateCollection(assignedCol.id, {
name: "assigned_chores",
type: "base",
listRule: assignedCol.listRule,
viewRule: assignedCol.viewRule,
createRule: assignedCol.createRule,
updateRule: assignedCol.updateRule,
deleteRule: assignedCol.deleteRule,
fields: assignedCol.fields,
});
}
} else {
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
}
console.log("[migrate] Done");
}