diff --git a/api/src/routes/admin.ts b/api/src/routes/admin.ts new file mode 100644 index 0000000..21956da --- /dev/null +++ b/api/src/routes/admin.ts @@ -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 = 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; diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts new file mode 100644 index 0000000..e177d11 --- /dev/null +++ b/api/src/routes/dashboard.ts @@ -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(); + incomeEvents.forEach((evt) => { + const key = monthKey(evt.postedAt); + incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n)); + }); + const spendByMonth = new Map(); + 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; diff --git a/api/src/routes/site-access.ts b/api/src/routes/site-access.ts new file mode 100644 index 0000000..d660f21 --- /dev/null +++ b/api/src/routes/site-access.ts @@ -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 }) => boolean; + safeEqual: (a: string, b: string) => boolean; +}; + +const siteAccessRoutes: FastifyPluginAsync = 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; diff --git a/api/src/server.ts b/api/src/server.ts index 6b08b96..899ea47 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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(); - incomeEvents.forEach((evt) => { - const key = monthKey(evt.postedAt); - incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n)); - }); - const spendByMonth = new Map(); - 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; } diff --git a/docs/api-phase7-move-log.md b/docs/api-phase7-move-log.md new file mode 100644 index 0000000..d57474a --- /dev/null +++ b/docs/api-phase7-move-log.md @@ -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. diff --git a/docs/api-phase8-move-log.md b/docs/api-phase8-move-log.md new file mode 100644 index 0000000..2607359 --- /dev/null +++ b/docs/api-phase8-move-log.md @@ -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. diff --git a/docs/api-refactor-lightweight-plan.md b/docs/api-refactor-lightweight-plan.md index 4809e9c..9b746c1 100644 --- a/docs/api-refactor-lightweight-plan.md +++ b/docs/api-refactor-lightweight-plan.md @@ -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)