phase 1 of cleanup: move GET health, GET auth/session, and PATCH endpoints
This commit is contained in:
@@ -12,6 +12,9 @@ import { z } from "zod";
|
||||
import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } 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";
|
||||
|
||||
export type AppConfig = typeof env;
|
||||
|
||||
@@ -1512,41 +1515,13 @@ app.post("/auth/refresh", async (req, reply) => {
|
||||
return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES };
|
||||
});
|
||||
|
||||
app.get("/auth/session", async (req, reply) => {
|
||||
if (!(req.cookies as any)?.[CSRF_COOKIE]) {
|
||||
ensureCsrfCookie(reply);
|
||||
}
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
select: {
|
||||
email: true,
|
||||
displayName: true,
|
||||
emailVerified: true,
|
||||
seenUpdateVersion: true,
|
||||
},
|
||||
});
|
||||
const noticeVersion = config.UPDATE_NOTICE_VERSION;
|
||||
const shouldShowNotice =
|
||||
noticeVersion > 0 &&
|
||||
!!user &&
|
||||
user.emailVerified &&
|
||||
user.seenUpdateVersion < noticeVersion;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
userId: req.userId,
|
||||
email: user?.email ?? null,
|
||||
displayName: user?.displayName ?? null,
|
||||
emailVerified: user?.emailVerified ?? false,
|
||||
updateNotice: shouldShowNotice
|
||||
? {
|
||||
version: noticeVersion,
|
||||
title: config.UPDATE_NOTICE_TITLE,
|
||||
body: config.UPDATE_NOTICE_BODY,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
await app.register(sessionRoutes, {
|
||||
config,
|
||||
cookieDomain,
|
||||
mutationRateLimit,
|
||||
});
|
||||
await app.register(userRoutes);
|
||||
await app.register(healthRoutes, { nodeEnv: config.NODE_ENV });
|
||||
|
||||
app.get("/site-access/status", async (req) => {
|
||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
||||
@@ -1607,99 +1582,6 @@ app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => {
|
||||
return { ok: true, enabled: config.UNDER_CONSTRUCTION_ENABLED, unlocked: false };
|
||||
});
|
||||
|
||||
app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
version: z.coerce.number().int().nonnegative().optional(),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const targetVersion = parsed.data.version ?? config.UPDATE_NOTICE_VERSION;
|
||||
await app.prisma.user.updateMany({
|
||||
where: {
|
||||
id: req.userId,
|
||||
seenUpdateVersion: { lt: targetVersion },
|
||||
},
|
||||
data: { seenUpdateVersion: targetVersion },
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.patch("/me", async (req, reply) => {
|
||||
const Body = z.object({
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const updated = await app.prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { displayName: parsed.data.displayName.trim() },
|
||||
select: { id: true, email: true, displayName: true },
|
||||
});
|
||||
return { ok: true, userId: updated.id, email: updated.email, displayName: updated.displayName };
|
||||
});
|
||||
|
||||
app.patch("/me/password", async (req, reply) => {
|
||||
const Body = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid password data" });
|
||||
}
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
|
||||
if (!user?.passwordHash) {
|
||||
return reply.code(401).send({ ok: false, message: "No password set" });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const valid = await argon2.verify(user.passwordHash, parsed.data.currentPassword);
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ ok: false, message: "Current password is incorrect" });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS);
|
||||
|
||||
// Update password
|
||||
await app.prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { passwordHash: newHash, passwordChangedAt: new Date() },
|
||||
});
|
||||
|
||||
return { ok: true, message: "Password updated successfully" };
|
||||
});
|
||||
|
||||
app.patch("/me/income-frequency", async (req, reply) => {
|
||||
const Body = z.object({
|
||||
incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid income frequency data" });
|
||||
}
|
||||
const updated = await app.prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: {
|
||||
incomeFrequency: parsed.data.incomeFrequency,
|
||||
},
|
||||
select: { id: true, incomeFrequency: true },
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
incomeFrequency: updated.incomeFrequency,
|
||||
};
|
||||
});
|
||||
|
||||
app.addHook("preSerialization", (_req, _reply, payload, done) => {
|
||||
try {
|
||||
if (payload && typeof payload === "object") {
|
||||
@@ -1761,18 +1643,6 @@ app.post("/admin/rollover", async (req, reply) => {
|
||||
return { ok: true, asOf: asOf.toISOString(), dryRun, processed: results.length, results };
|
||||
});
|
||||
|
||||
// ----- Health -----
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
if (config.NODE_ENV !== "production") {
|
||||
app.get("/health/db", async () => {
|
||||
const start = Date.now();
|
||||
const [{ now }] =
|
||||
await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`;
|
||||
const latencyMs = Date.now() - start;
|
||||
return { ok: true, nowISO: now.toISOString(), latencyMs };
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Dashboard -----
|
||||
app.get("/dashboard", async (req) => {
|
||||
const userId = req.userId;
|
||||
@@ -4825,95 +4695,6 @@ app.post("/budget/reconcile", mutationRateLimit, async (req, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
const UserConfigBody = z.object({
|
||||
incomeType: z.enum(["regular", "irregular"]).optional(),
|
||||
totalBudgetCents: z.number().int().nonnegative().optional(),
|
||||
budgetPeriod: z.enum(["weekly", "biweekly", "monthly"]).optional(),
|
||||
incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]).optional(),
|
||||
firstIncomeDate: z
|
||||
.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)])
|
||||
.nullable()
|
||||
.optional(),
|
||||
timezone: z.string().refine((value) => {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Invalid timezone").optional(), // IANA timezone identifier
|
||||
fixedExpensePercentage: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
app.patch("/user/config", async (req, reply) => {
|
||||
const parsed = UserConfigBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ message: "Invalid user config data" });
|
||||
}
|
||||
|
||||
const userId = req.userId;
|
||||
const updateData: any = {};
|
||||
const scheduleChange =
|
||||
parsed.data.incomeFrequency !== undefined ||
|
||||
parsed.data.firstIncomeDate !== undefined;
|
||||
const wantsFirstIncomeDate = parsed.data.firstIncomeDate !== undefined;
|
||||
|
||||
if (parsed.data.incomeFrequency) updateData.incomeFrequency = parsed.data.incomeFrequency;
|
||||
if (parsed.data.totalBudgetCents !== undefined) updateData.totalBudgetCents = BigInt(parsed.data.totalBudgetCents);
|
||||
if (parsed.data.budgetPeriod) updateData.budgetPeriod = parsed.data.budgetPeriod;
|
||||
if (parsed.data.incomeType) updateData.incomeType = parsed.data.incomeType;
|
||||
if (parsed.data.timezone) updateData.timezone = parsed.data.timezone;
|
||||
if (parsed.data.fixedExpensePercentage !== undefined) {
|
||||
updateData.fixedExpensePercentage = parsed.data.fixedExpensePercentage;
|
||||
}
|
||||
|
||||
const updated = await app.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { incomeType: true, timezone: true },
|
||||
});
|
||||
const effectiveTimezone = parsed.data.timezone ?? existing?.timezone ?? "America/New_York";
|
||||
if (wantsFirstIncomeDate) {
|
||||
updateData.firstIncomeDate = parsed.data.firstIncomeDate
|
||||
? getUserMidnightFromDateOnly(effectiveTimezone, new Date(parsed.data.firstIncomeDate))
|
||||
: null;
|
||||
}
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
incomeFrequency: true,
|
||||
incomeType: true,
|
||||
totalBudgetCents: true,
|
||||
budgetPeriod: true,
|
||||
firstIncomeDate: true,
|
||||
timezone: true,
|
||||
fixedExpensePercentage: true,
|
||||
},
|
||||
});
|
||||
const finalIncomeType = updateData.incomeType ?? existing?.incomeType ?? "regular";
|
||||
if (scheduleChange && finalIncomeType === "regular") {
|
||||
await tx.fixedPlan.updateMany({
|
||||
where: { userId, paymentSchedule: { not: Prisma.DbNull } },
|
||||
data: { needsFundingThisPeriod: true },
|
||||
});
|
||||
}
|
||||
return updatedUser;
|
||||
});
|
||||
|
||||
return {
|
||||
incomeFrequency: updated.incomeFrequency,
|
||||
incomeType: updated.incomeType || "regular",
|
||||
totalBudgetCents: updated.totalBudgetCents ? Number(updated.totalBudgetCents) : null,
|
||||
budgetPeriod: updated.budgetPeriod,
|
||||
firstIncomeDate: updated.firstIncomeDate
|
||||
? getUserMidnightFromDateOnly(updated.timezone ?? "America/New_York", updated.firstIncomeDate).toISOString()
|
||||
: null,
|
||||
timezone: updated.timezone,
|
||||
fixedExpensePercentage: updated.fixedExpensePercentage ?? 40,
|
||||
};
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user