added todos | payday time settings | refactor payment scheduling
This commit is contained in:
+476
-43
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user