phase 8: site-access and admin simplified and compacted
This commit is contained in:
33
api/src/routes/admin.ts
Normal file
33
api/src/routes/admin.ts
Normal 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
333
api/src/routes/dashboard.ts
Normal 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;
|
||||
90
api/src/routes/site-access.ts
Normal file
90
api/src/routes/site-access.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
48
docs/api-phase7-move-log.md
Normal file
48
docs/api-phase7-move-log.md
Normal 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.
|
||||
70
docs/api-phase8-move-log.md
Normal file
70
docs/api-phase8-move-log.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user