phase 8: site-access and admin simplified and compacted
All checks were successful
Deploy / deploy (push) Successful in 1m32s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 27s

This commit is contained in:
2026-03-18 06:43:19 -05:00
parent a8e5443b0d
commit 952684fc25
7 changed files with 601 additions and 405 deletions

33
api/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { rolloverFixedPlans } from "../jobs/rollover.js";
type AdminRoutesOptions = {
authDisabled: boolean;
isInternalClientIp: (ip: string) => boolean;
};
const adminRoutes: FastifyPluginAsync<AdminRoutesOptions> = async (app, opts) => {
app.post("/admin/rollover", async (req, reply) => {
if (!opts.authDisabled) {
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
if (!opts.isInternalClientIp(req.ip || "")) {
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
const Body = z.object({
asOf: z.string().datetime().optional(),
dryRun: z.boolean().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const asOf = parsed.data.asOf ? new Date(parsed.data.asOf) : new Date();
const dryRun = parsed.data.dryRun ?? false;
const results = await rolloverFixedPlans(app.prisma, asOf, { dryRun });
return { ok: true, asOf: asOf.toISOString(), dryRun, processed: results.length, results };
});
};
export default adminRoutes;

333
api/src/routes/dashboard.ts Normal file
View File

@@ -0,0 +1,333 @@
import type { FastifyPluginAsync } from "fastify";
import { getUserMidnightFromDateOnly } from "../allocator.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const monthKey = (date: Date) =>
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
const monthLabel = (date: Date) =>
date.toLocaleString("en-US", { month: "short", year: "numeric" });
function buildMonthBuckets(count: number, now = new Date()) {
const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = [];
const current = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
for (let i = count - 1; i >= 0; i--) {
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
buckets.push({ key: monthKey(start), label: monthLabel(start), start, end });
}
return buckets;
}
const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.get("/dashboard", async (req) => {
const userId = req.userId;
const monthsBack = 6;
const buckets = buildMonthBuckets(monthsBack);
const rangeStart = buckets[0]?.start ?? new Date();
const now = new Date();
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, spendTxs, user] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
}),
app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
}),
app.prisma.transaction.findMany({
where: { userId, kind: { in: dashboardTxKinds } },
orderBy: { occurredAt: "desc" },
take: 50,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
app.prisma.incomeEvent.aggregate({
where: { userId },
_sum: { amountCents: true },
}),
app.prisma.allocation.aggregate({
where: { userId },
_sum: { amountCents: true },
}),
app.prisma.incomeEvent.findMany({
where: { userId, postedAt: { gte: rangeStart } },
select: { postedAt: true, amountCents: true },
}),
app.prisma.transaction.findMany({
where: {
userId,
kind: { in: dashboardTxKinds },
occurredAt: { gte: rangeStart },
},
select: { occurredAt: true, amountCents: true },
}),
app.prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
displayName: true,
incomeFrequency: true,
incomeType: true,
timezone: true,
firstIncomeDate: true,
fixedExpensePercentage: true,
},
}),
]);
const totalIncomeCents = Number(agg._sum?.amountCents ?? 0n);
const totalAllocatedCents = Number(allocAgg._sum?.amountCents ?? 0n);
const availableBudgetCents = Math.max(0, totalIncomeCents - totalAllocatedCents);
const { getUserMidnight, calculateNextPayday } = await import("../allocator.js");
const userTimezone = user?.timezone || "America/New_York";
const userNow = getUserMidnight(userTimezone, now);
const upcomingCutoff = new Date(userNow.getTime() + 14 * DAY_MS);
const fixedPlans = plans.map((plan) => {
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const fundedPercent = total > 0 ? (funded / total) * 100 : 100;
const CRISIS_MINIMUM_CENTS = 1000;
const isPaymentPlanUser = user?.incomeType === "regular" && plan.paymentSchedule !== null;
let isCrisis = false;
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
if (isPaymentPlanUser && user?.firstIncomeDate) {
const nextPayday = calculateNextPayday(user.firstIncomeDate, user.incomeFrequency, now, userTimezone);
const daysUntilPayday = Math.max(0, Math.ceil((nextPayday.getTime() - userNow.getTime()) / DAY_MS));
isCrisis = daysUntilDue < daysUntilPayday && fundedPercent < 90;
} else {
isCrisis = fundedPercent < 70 && daysUntilDue <= 14;
}
}
return {
...plan,
fundedCents: funded,
currentFundedCents: funded,
remainingCents,
daysUntilDue,
percentFunded,
isCrisis,
};
});
const variableBalanceCents = Number(
cats.reduce((sum, cat) => sum + (cat.balanceCents ?? 0n), 0n)
);
const fixedFundedCents = Number(
fixedPlans.reduce((sum, plan) => sum + plan.fundedCents, 0)
);
const currentTotalBalance = variableBalanceCents + fixedFundedCents;
const totals = {
incomeCents: currentTotalBalance,
availableBudgetCents,
variableBalanceCents,
fixedRemainingCents: Number(
fixedPlans.reduce((sum, plan) => sum + Math.max(0, plan.remainingCents), 0)
),
};
const percentTotal = cats.reduce((sum, cat) => sum + cat.percent, 0);
const incomeByMonth = new Map<string, number>();
incomeEvents.forEach((evt) => {
const key = monthKey(evt.postedAt);
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
});
const spendByMonth = new Map<string, number>();
spendTxs.forEach((tx) => {
const key = monthKey(tx.occurredAt);
spendByMonth.set(key, (spendByMonth.get(key) ?? 0) + Number(tx.amountCents ?? 0n));
});
const monthlyTrend = buckets.map((bucket) => ({
monthKey: bucket.key,
label: bucket.label,
incomeCents: incomeByMonth.get(bucket.key) ?? 0,
spendCents: spendByMonth.get(bucket.key) ?? 0,
}));
const upcomingPlans = fixedPlans
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
.filter(
(plan) =>
plan.remainingCents > 0 &&
plan.due >= userNow &&
plan.due <= upcomingCutoff
)
.sort((a, b) => a.due.getTime() - b.due.getTime())
.map((plan) => ({
id: plan.id,
name: plan.name,
dueOn: plan.due.toISOString(),
remainingCents: plan.remainingCents,
percentFunded: plan.percentFunded,
daysUntilDue: plan.daysUntilDue,
isCrisis: plan.isCrisis,
}));
const savingsTargets = cats
.filter((cat) => cat.isSavings && (cat.savingsTargetCents ?? 0n) > 0n)
.map((cat) => {
const target = Number(cat.savingsTargetCents ?? 0n);
const current = Number(cat.balanceCents ?? 0n);
const percent = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
return {
id: cat.id,
name: cat.name,
balanceCents: current,
targetCents: target,
percent,
};
});
const crisisAlerts = fixedPlans
.filter((plan) => plan.isCrisis && plan.remainingCents > 0)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
})
.map((plan) => ({
id: plan.id,
name: plan.name,
remainingCents: plan.remainingCents,
daysUntilDue: plan.daysUntilDue,
percentFunded: plan.percentFunded,
}));
function shouldFundFixedPlans(
userType: string,
incomeFrequency: string,
currentPlans: any[],
crisisActive: boolean
) {
if (crisisActive) return true;
if (userType === "irregular") {
return currentPlans.some((plan) => {
const remaining = Number(plan.remainingCents ?? 0);
return remaining > 0;
});
}
return currentPlans.some((plan) => {
const remaining = Number(plan.remainingCents ?? 0);
if (remaining <= 0) return false;
return plan.needsFundingThisPeriod === true;
});
}
const needsFixedFunding = shouldFundFixedPlans(
user?.incomeType ?? "regular",
user?.incomeFrequency ?? "biweekly",
fixedPlans,
crisisAlerts.length > 0
);
const hasBudgetSetup = cats.length > 0 && percentTotal === 100;
return {
totals,
variableCategories: cats,
fixedPlans: fixedPlans.map((plan) => ({
...plan,
dueOn: getUserMidnightFromDateOnly(userTimezone, plan.dueOn).toISOString(),
lastFundingDate: plan.lastFundingDate ? new Date(plan.lastFundingDate).toISOString() : null,
})),
recentTransactions: recentTxs,
percentTotal,
hasBudgetSetup,
user: {
id: userId,
email: user?.email ?? null,
displayName: user?.displayName ?? null,
incomeFrequency: user?.incomeFrequency ?? "biweekly",
incomeType: user?.incomeType ?? "regular",
timezone: user?.timezone ?? "America/New_York",
firstIncomeDate: user?.firstIncomeDate
? getUserMidnightFromDateOnly(userTimezone, user.firstIncomeDate).toISOString()
: null,
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
},
monthlyTrend,
upcomingPlans,
savingsTargets,
crisis: {
active: crisisAlerts.length > 0,
plans: crisisAlerts,
},
needsFixedFunding,
};
});
app.get("/crisis-status", async (req) => {
const userId = req.userId;
const now = new Date();
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const { getUserMidnight } = await import("../allocator.js");
const userNow = getUserMidnight(userTimezone, now);
const plans = await app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
priority: true,
},
});
const crisisPlans = plans
.map((plan) => {
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
const daysUntilDue = Math.max(
0,
Math.ceil((userDueDate.getTime() - userNow.getTime()) / (24 * 60 * 60 * 1000))
);
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const isCrisis = remainingCents > 0 && daysUntilDue <= 7;
return {
id: plan.id,
name: plan.name,
remainingCents,
daysUntilDue,
percentFunded,
priority: plan.priority,
isCrisis,
};
})
.filter((plan) => plan.isCrisis)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
});
return {
active: crisisPlans.length > 0,
plans: crisisPlans,
};
});
};
export default dashboardRoutes;

View File

@@ -0,0 +1,90 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type SiteAccessRoutesOptions = {
underConstructionEnabled: boolean;
breakGlassVerifyEnabled: boolean;
breakGlassVerifyCode: string | null;
siteAccessExpectedToken: string | null;
cookieDomain?: string;
secureCookie: boolean;
siteAccessCookieName: string;
siteAccessMaxAgeSeconds: number;
authRateLimit: RateLimitRouteOptions;
mutationRateLimit: RateLimitRouteOptions;
hasSiteAccessBypass: (req: { cookies?: Record<string, unknown> }) => boolean;
safeEqual: (a: string, b: string) => boolean;
};
const siteAccessRoutes: FastifyPluginAsync<SiteAccessRoutesOptions> = async (app, opts) => {
app.get("/site-access/status", async (req) => {
if (!opts.underConstructionEnabled) {
return { ok: true, enabled: false, unlocked: true };
}
return {
ok: true,
enabled: true,
unlocked: opts.hasSiteAccessBypass(req),
};
});
app.post("/site-access/unlock", opts.authRateLimit, async (req, reply) => {
const Body = z.object({
code: z.string().min(1).max(512),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
}
if (!opts.underConstructionEnabled) {
return { ok: true, enabled: false, unlocked: true };
}
if (!opts.breakGlassVerifyEnabled || !opts.siteAccessExpectedToken) {
return reply.code(503).send({
ok: false,
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
message: "Under-construction access is not configured.",
});
}
if (!opts.breakGlassVerifyCode || !opts.safeEqual(parsed.data.code, opts.breakGlassVerifyCode)) {
return reply.code(401).send({
ok: false,
code: "INVALID_ACCESS_CODE",
message: "Invalid access code.",
});
}
reply.setCookie(opts.siteAccessCookieName, opts.siteAccessExpectedToken, {
httpOnly: true,
sameSite: "lax",
secure: opts.secureCookie,
path: "/",
maxAge: opts.siteAccessMaxAgeSeconds,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
return { ok: true, enabled: true, unlocked: true };
});
app.post("/site-access/lock", opts.mutationRateLimit, async (_req, reply) => {
reply.clearCookie(opts.siteAccessCookieName, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: opts.secureCookie,
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
});
return { ok: true, enabled: opts.underConstructionEnabled, unlocked: false };
});
};
export default siteAccessRoutes;

View File

@@ -8,9 +8,8 @@ import nodemailer from "nodemailer";
import { env } from "./env.js";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
import { getUserMidnight, getUserMidnightFromDateOnly } from "./allocator.js";
import { getUserMidnightFromDateOnly } from "./allocator.js";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { rolloverFixedPlans } from "./jobs/rollover.js";
import healthRoutes from "./routes/health.js";
import sessionRoutes from "./routes/session.js";
import userRoutes from "./routes/user.js";
@@ -21,6 +20,9 @@ import fixedPlansRoutes from "./routes/fixed-plans.js";
import incomeRoutes from "./routes/income.js";
import paydayRoutes from "./routes/payday.js";
import budgetRoutes from "./routes/budget.js";
import dashboardRoutes from "./routes/dashboard.js";
import siteAccessRoutes from "./routes/site-access.js";
import adminRoutes from "./routes/admin.js";
export type AppConfig = typeof env;
@@ -448,22 +450,6 @@ function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone:
zoned.setUTCHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
const monthKey = (date: Date) =>
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
const monthLabel = (date: Date) =>
date.toLocaleString("en-US", { month: "short", year: "numeric" });
function buildMonthBuckets(count: number, now = new Date()) {
const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = [];
const current = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
for (let i = count - 1; i >= 0; i--) {
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
buckets.push({ key: monthKey(start), label: monthLabel(start), start, end });
}
return buckets;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function jsonBigIntSafe(obj: unknown) {
return JSON.parse(
JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
@@ -956,64 +942,24 @@ await app.register(budgetRoutes, {
computeWithdrawShares,
isProd,
});
app.get("/site-access/status", async (req) => {
if (!config.UNDER_CONSTRUCTION_ENABLED) {
return { ok: true, enabled: false, unlocked: true };
}
return {
ok: true,
enabled: true,
unlocked: hasSiteAccessBypass(req),
};
await app.register(dashboardRoutes);
await app.register(siteAccessRoutes, {
underConstructionEnabled: config.UNDER_CONSTRUCTION_ENABLED,
breakGlassVerifyEnabled: config.BREAK_GLASS_VERIFY_ENABLED,
breakGlassVerifyCode: config.BREAK_GLASS_VERIFY_CODE ?? null,
siteAccessExpectedToken,
cookieDomain,
secureCookie: config.NODE_ENV === "production",
siteAccessCookieName: SITE_ACCESS_COOKIE,
siteAccessMaxAgeSeconds: SITE_ACCESS_MAX_AGE_SECONDS,
authRateLimit,
mutationRateLimit,
hasSiteAccessBypass,
safeEqual,
});
app.post("/site-access/unlock", authRateLimit, async (req, reply) => {
const Body = z.object({
code: z.string().min(1).max(512),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
}
if (!config.UNDER_CONSTRUCTION_ENABLED) {
return { ok: true, enabled: false, unlocked: true };
}
if (!config.BREAK_GLASS_VERIFY_ENABLED || !siteAccessExpectedToken) {
return reply.code(503).send({
ok: false,
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
message: "Under-construction access is not configured.",
});
}
if (!safeEqual(parsed.data.code, config.BREAK_GLASS_VERIFY_CODE!)) {
return reply.code(401).send({
ok: false,
code: "INVALID_ACCESS_CODE",
message: "Invalid access code.",
});
}
reply.setCookie(SITE_ACCESS_COOKIE, siteAccessExpectedToken, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge: SITE_ACCESS_MAX_AGE_SECONDS,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true, enabled: true, unlocked: true };
});
app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => {
reply.clearCookie(SITE_ACCESS_COOKIE, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true, enabled: config.UNDER_CONSTRUCTION_ENABLED, unlocked: false };
await app.register(adminRoutes, {
authDisabled: config.AUTH_DISABLED,
isInternalClientIp,
});
app.addHook("preSerialization", (_req, _reply, payload, done) => {
@@ -1056,332 +1002,6 @@ app.setNotFoundHandler((req, reply) => {
});
});
app.post("/admin/rollover", async (req, reply) => {
if (!config.AUTH_DISABLED) {
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
if (!isInternalClientIp(req.ip || "")) {
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
const Body = z.object({
asOf: z.string().datetime().optional(),
dryRun: z.boolean().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const asOf = parsed.data.asOf ? new Date(parsed.data.asOf) : new Date();
const dryRun = parsed.data.dryRun ?? false;
const results = await rolloverFixedPlans(app.prisma, asOf, { dryRun });
return { ok: true, asOf: asOf.toISOString(), dryRun, processed: results.length, results };
});
// ----- Dashboard -----
app.get("/dashboard", async (req) => {
const userId = req.userId;
const monthsBack = 6;
const buckets = buildMonthBuckets(monthsBack);
const rangeStart = buckets[0]?.start ?? new Date();
const now = new Date();
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, spendTxs, user] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
}),
app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
}),
app.prisma.transaction.findMany({
where: { userId, kind: { in: dashboardTxKinds } },
orderBy: { occurredAt: "desc" },
take: 50,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
app.prisma.incomeEvent.aggregate({
where: { userId },
_sum: { amountCents: true },
}),
app.prisma.allocation.aggregate({
where: { userId },
_sum: { amountCents: true },
}),
app.prisma.incomeEvent.findMany({
where: { userId, postedAt: { gte: rangeStart } },
select: { postedAt: true, amountCents: true },
}),
app.prisma.transaction.findMany({
where: {
userId,
kind: { in: dashboardTxKinds },
occurredAt: { gte: rangeStart },
},
select: { occurredAt: true, amountCents: true },
}),
app.prisma.user.findUnique({
where: { id: userId },
select: { email: true, displayName: true, incomeFrequency: true, incomeType: true, timezone: true, firstIncomeDate: true, fixedExpensePercentage: true },
}),
]);
const totalIncomeCents = Number(agg._sum?.amountCents ?? 0n);
const totalAllocatedCents = Number(allocAgg._sum?.amountCents ?? 0n);
const availableBudgetCents = Math.max(0, totalIncomeCents - totalAllocatedCents);
// Import timezone-aware helper for consistent date calculations
const { getUserMidnight, calculateNextPayday } = await import("./allocator.js");
const userTimezone = user?.timezone || "America/New_York";
const userNow = getUserMidnight(userTimezone, now);
const upcomingCutoff = new Date(userNow.getTime() + 14 * DAY_MS);
const fixedPlans = plans.map((plan) => {
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
// Use timezone-aware date comparison for consistency with allocator
const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const fundedPercent = total > 0 ? (funded / total) * 100 : 100;
// Use same crisis logic as allocator for consistency
const CRISIS_MINIMUM_CENTS = 1000;
const isPaymentPlanUser = user?.incomeType === "regular" && plan.paymentSchedule !== null;
let isCrisis = false;
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
if (isPaymentPlanUser && user?.firstIncomeDate) {
// Crisis if due BEFORE next payday AND not mostly funded
const nextPayday = calculateNextPayday(user.firstIncomeDate, user.incomeFrequency, now, userTimezone);
const daysUntilPayday = Math.max(0, Math.ceil((nextPayday.getTime() - userNow.getTime()) / DAY_MS));
isCrisis = daysUntilDue < daysUntilPayday && fundedPercent < 90;
} else {
// For irregular income users
isCrisis = fundedPercent < 70 && daysUntilDue <= 14;
}
}
return {
...plan,
fundedCents: funded,
currentFundedCents: funded,
remainingCents,
daysUntilDue,
percentFunded,
isCrisis,
};
});
const variableBalanceCents = Number(
cats.reduce((sum, cat) => sum + (cat.balanceCents ?? 0n), 0n)
);
const fixedFundedCents = Number(
fixedPlans.reduce((sum, plan) => sum + plan.fundedCents, 0)
);
const currentTotalBalance = variableBalanceCents + fixedFundedCents;
const totals = {
incomeCents: currentTotalBalance, // Changed: show current balance instead of lifetime income
availableBudgetCents,
variableBalanceCents,
fixedRemainingCents: Number(
fixedPlans.reduce((sum, plan) => sum + Math.max(0, plan.remainingCents), 0)
),
};
const percentTotal = cats.reduce((sum, cat) => sum + cat.percent, 0);
const incomeByMonth = new Map<string, number>();
incomeEvents.forEach((evt) => {
const key = monthKey(evt.postedAt);
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
});
const spendByMonth = new Map<string, number>();
spendTxs.forEach((tx) => {
const key = monthKey(tx.occurredAt);
spendByMonth.set(key, (spendByMonth.get(key) ?? 0) + Number(tx.amountCents ?? 0n));
});
const monthlyTrend = buckets.map((bucket) => ({
monthKey: bucket.key,
label: bucket.label,
incomeCents: incomeByMonth.get(bucket.key) ?? 0,
spendCents: spendByMonth.get(bucket.key) ?? 0,
}));
const upcomingPlans = fixedPlans
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
.filter(
(plan) =>
plan.remainingCents > 0 &&
plan.due >= userNow &&
plan.due <= upcomingCutoff
)
.sort((a, b) => a.due.getTime() - b.due.getTime())
.map((plan) => ({
id: plan.id,
name: plan.name,
dueOn: plan.due.toISOString(),
remainingCents: plan.remainingCents,
percentFunded: plan.percentFunded,
daysUntilDue: plan.daysUntilDue,
isCrisis: plan.isCrisis,
}));
const savingsTargets = cats
.filter((cat) => cat.isSavings && (cat.savingsTargetCents ?? 0n) > 0n)
.map((cat) => {
const target = Number(cat.savingsTargetCents ?? 0n);
const current = Number(cat.balanceCents ?? 0n);
const percent = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
return {
id: cat.id,
name: cat.name,
balanceCents: current,
targetCents: target,
percent,
};
});
const crisisAlerts = fixedPlans
.filter((plan) => plan.isCrisis && plan.remainingCents > 0)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
})
.map((plan) => ({
id: plan.id,
name: plan.name,
remainingCents: plan.remainingCents,
daysUntilDue: plan.daysUntilDue,
percentFunded: plan.percentFunded,
}));
// Simplified fixed funding detection using tracking flags
function shouldFundFixedPlans(userType: string, incomeFrequency: string, fixedPlans: any[], crisisActive: boolean) {
// 1. Crisis mode = always fund fixed
if (crisisActive) return true;
// 2. Irregular users = always fund until fully funded
if (userType === "irregular") {
return fixedPlans.some(plan => {
const remaining = Number(plan.remainingCents ?? 0);
return remaining > 0;
});
}
// 3. Regular users = use simple flag-based detection
// Plans needing funding will have needsFundingThisPeriod = true
return fixedPlans.some(plan => {
const remaining = Number(plan.remainingCents ?? 0);
if (remaining <= 0) return false; // Already fully funded
// Simple check: does this plan need funding this period?
return plan.needsFundingThisPeriod === true;
});
}
const needsFixedFunding = shouldFundFixedPlans(
user?.incomeType ?? "regular",
user?.incomeFrequency ?? "biweekly",
fixedPlans,
crisisAlerts.length > 0
);
const hasBudgetSetup = cats.length > 0 && percentTotal === 100;
return {
totals,
variableCategories: cats,
fixedPlans: fixedPlans.map((plan) => ({
...plan,
dueOn: getUserMidnightFromDateOnly(userTimezone, plan.dueOn).toISOString(),
lastFundingDate: plan.lastFundingDate ? new Date(plan.lastFundingDate).toISOString() : null,
})),
recentTransactions: recentTxs,
percentTotal,
hasBudgetSetup,
user: {
id: userId,
email: user?.email ?? null,
displayName: user?.displayName ?? null,
incomeFrequency: user?.incomeFrequency ?? "biweekly",
incomeType: user?.incomeType ?? "regular",
timezone: user?.timezone ?? "America/New_York",
firstIncomeDate: user?.firstIncomeDate
? getUserMidnightFromDateOnly(userTimezone, user.firstIncomeDate).toISOString()
: null,
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
},
monthlyTrend,
upcomingPlans,
savingsTargets,
crisis: {
active: crisisAlerts.length > 0,
plans: crisisAlerts,
},
needsFixedFunding,
};
});
app.get("/crisis-status", async (req) => {
const userId = req.userId;
const now = new Date();
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const userNow = getUserMidnight(userTimezone, now);
const plans = await app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
priority: true,
},
});
const crisisPlans = plans
.map((plan) => {
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / (24 * 60 * 60 * 1000)));
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const isCrisis = remainingCents > 0 && daysUntilDue <= 7;
return {
id: plan.id,
name: plan.name,
remainingCents,
daysUntilDue,
percentFunded,
priority: plan.priority,
isCrisis,
};
})
.filter((plan) => plan.isCrisis)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
});
return {
active: crisisPlans.length > 0,
plans: crisisPlans,
};
});
return app;
}

View File

@@ -0,0 +1,48 @@
# API Phase 7 Move Log
Date: 2026-03-17
Scope: Move dashboard read endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Added dashboard route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24)
- Registered dashboard routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:944)
- New canonical route module: [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24)
- Removed inline dashboard route blocks from `server.ts` to avoid duplicate registration:
- `GET /dashboard`
- `GET /crisis-status`
## Endpoint Movements
1. `GET /dashboard`
- Original: `server.ts` line 1081
- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:24)
- References:
- [useDashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useDashboard.ts:85)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:172)
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:37)
- [security-logging-monitoring-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-logging-monitoring-failures.test.ts:48)
2. `GET /crisis-status`
- Original: `server.ts` line 1330
- Moved to [dashboard.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/dashboard.ts:273)
- References:
- No direct web/api wrapper references currently found via repo search.
- Endpoint remains available for API consumers and future UI wiring.
## Helper Ownership in Phase 7
- Route-local helpers in `dashboard.ts`:
- `monthKey`
- `monthLabel`
- `buildMonthBuckets`
- `DAY_MS`
- Reused allocator date helpers:
- static `getUserMidnightFromDateOnly`
- dynamic import of `getUserMidnight` and `calculateNextPayday` for parity with pre-move logic
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/auth.routes.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions.

View File

@@ -0,0 +1,70 @@
# API Phase 8 Move Log
Date: 2026-03-17
Scope: Move `admin` and `site-access` endpoints out of `api/src/server.ts` into dedicated route modules.
## Route Registration Changes
- Added site-access route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:24)
- Added admin route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:25)
- Registered site-access routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946)
- Registered admin routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:960)
- New canonical route modules:
- [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:29)
- [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:10)
- Removed inline route blocks from `server.ts` to avoid duplicate registration:
- `GET /site-access/status`
- `POST /site-access/unlock`
- `POST /site-access/lock`
- `POST /admin/rollover`
## Endpoint Movements
1. `GET /site-access/status`
- Original: `server.ts` line 946
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:30)
- References:
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:10)
- [BetaGate.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/BetaGate.tsx:20)
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:22)
2. `POST /site-access/unlock`
- Original: `server.ts` line 957
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:41)
- References:
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:14)
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:40)
3. `POST /site-access/lock`
- Original: `server.ts` line 994
- Moved to [site-access.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/site-access.ts:78)
- References:
- [siteAccess.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/siteAccess.ts:18)
- [BetaAccessPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/BetaAccessPage.tsx:59)
4. `POST /admin/rollover`
- Original: `server.ts` line 1045
- Moved to [admin.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/admin.ts:11)
- References:
- [access-control.admin-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/access-control.admin-rollover.test.ts:44)
## Helper Ownership in Phase 8
- Shared helper injection from `server.ts`:
- `authRateLimit`
- `mutationRateLimit`
- `hasSiteAccessBypass`
- `safeEqual`
- `isInternalClientIp`
- runtime config flags and cookie settings (`UNDER_CONSTRUCTION`, break-glass, cookie domain/secure, etc.)
- Route-local helpers/schemas:
- `site-access.ts`: unlock payload schema
- `admin.ts`: rollover payload schema
- Retained in `server.ts` by design for global hook behavior:
- site-access bypass token derivation and onRequest maintenance-mode enforcement
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/access-control.admin-rollover.test.ts tests/security-misconfiguration.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suite skipped/failed before endpoint assertions.

View File

@@ -4,7 +4,7 @@
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
Current state (2026-03-17):
- `server.ts` still holds most business routes, but Phases 1-6 are complete.
- `server.ts` still holds most business routes, but Phases 1-8 are complete.
- Completed move logs:
- `docs/api-phase1-move-log.md`
- `docs/api-phase2-move-log.md`
@@ -12,6 +12,8 @@ Current state (2026-03-17):
- `docs/api-phase4-move-log.md`
- `docs/api-phase5-move-log.md`
- `docs/api-phase6-move-log.md`
- `docs/api-phase7-move-log.md`
- `docs/api-phase8-move-log.md`
## Refactor Guardrails
1. Keep route behavior identical while moving code.
@@ -63,11 +65,11 @@ Completed:
4. Phase 4: `transactions` endpoints.
5. Phase 5: `fixed-plans` endpoints.
6. Phase 6: `income`, `budget`, `payday` endpoints.
7. Phase 7: `dashboard` + `crisis-status`.
8. Phase 8: `admin` + site access endpoints.
Remaining:
1. Phase 7: `dashboard` + `crisis-status`.
2. Phase 8: `admin` + site access endpoints.
3. Phase 9: final cleanup and helper consolidation.
1. Phase 9: final cleanup and helper consolidation.
## Remaining Plan (Detailed)