phase 1 of cleanup: move GET health, GET auth/session, and PATCH endpoints
This commit is contained in:
24
api/src/routes/health.ts
Normal file
24
api/src/routes/health.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
|
||||||
|
type HealthRoutesOptions = {
|
||||||
|
nodeEnv: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthRoutes: FastifyPluginAsync<HealthRoutesOptions> = async (
|
||||||
|
app,
|
||||||
|
opts
|
||||||
|
) => {
|
||||||
|
app.get("/health", async () => ({ ok: true }));
|
||||||
|
|
||||||
|
if (opts.nodeEnv !== "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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default healthRoutes;
|
||||||
101
api/src/routes/session.ts
Normal file
101
api/src/routes/session.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { AppConfig } from "../server.js";
|
||||||
|
|
||||||
|
type SessionRoutesOptions = {
|
||||||
|
config: Pick<
|
||||||
|
AppConfig,
|
||||||
|
"NODE_ENV" | "UPDATE_NOTICE_VERSION" | "UPDATE_NOTICE_TITLE" | "UPDATE_NOTICE_BODY"
|
||||||
|
>;
|
||||||
|
cookieDomain?: string;
|
||||||
|
mutationRateLimit: {
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: number;
|
||||||
|
timeWindow: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSRF_COOKIE = "csrf";
|
||||||
|
|
||||||
|
const sessionRoutes: FastifyPluginAsync<SessionRoutesOptions> = async (
|
||||||
|
app,
|
||||||
|
opts
|
||||||
|
) => {
|
||||||
|
const ensureCsrfCookie = (reply: any, existing?: string) => {
|
||||||
|
const token = existing ?? randomUUID().replace(/-/g, "");
|
||||||
|
reply.setCookie(CSRF_COOKIE, token, {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: opts.config.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = opts.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: opts.config.UPDATE_NOTICE_TITLE,
|
||||||
|
body: opts.config.UPDATE_NOTICE_BODY,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/app/update-notice/ack",
|
||||||
|
opts.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 ?? opts.config.UPDATE_NOTICE_VERSION;
|
||||||
|
await app.prisma.user.updateMany({
|
||||||
|
where: {
|
||||||
|
id: req.userId,
|
||||||
|
seenUpdateVersion: { lt: targetVersion },
|
||||||
|
},
|
||||||
|
data: { seenUpdateVersion: targetVersion },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sessionRoutes;
|
||||||
216
api/src/routes/user.ts
Normal file
216
api/src/routes/user.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getUserMidnightFromDateOnly } from "../allocator.js";
|
||||||
|
|
||||||
|
const PASSWORD_MIN_LENGTH = 12;
|
||||||
|
const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN_LENGTH)
|
||||||
|
.max(128)
|
||||||
|
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||||
|
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||||
|
.regex(/\d/, "Password must include a number")
|
||||||
|
.regex(/[^A-Za-z0-9]/, "Password must include a symbol");
|
||||||
|
|
||||||
|
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 19_456,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
fixedExpensePercentage: z.number().int().min(0).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await argon2.verify(
|
||||||
|
user.passwordHash,
|
||||||
|
parsed.data.currentPassword
|
||||||
|
);
|
||||||
|
if (!valid) {
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.send({ ok: false, message: "Current password is incorrect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS);
|
||||||
|
|
||||||
|
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.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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default userRoutes;
|
||||||
@@ -12,6 +12,9 @@ import { z } from "zod";
|
|||||||
import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } from "./allocator.js";
|
import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } from "./allocator.js";
|
||||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||||
import { rolloverFixedPlans } from "./jobs/rollover.js";
|
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;
|
export type AppConfig = typeof env;
|
||||||
|
|
||||||
@@ -1512,41 +1515,13 @@ app.post("/auth/refresh", async (req, reply) => {
|
|||||||
return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES };
|
return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/auth/session", async (req, reply) => {
|
await app.register(sessionRoutes, {
|
||||||
if (!(req.cookies as any)?.[CSRF_COOKIE]) {
|
config,
|
||||||
ensureCsrfCookie(reply);
|
cookieDomain,
|
||||||
}
|
mutationRateLimit,
|
||||||
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(userRoutes);
|
||||||
|
await app.register(healthRoutes, { nodeEnv: config.NODE_ENV });
|
||||||
|
|
||||||
app.get("/site-access/status", async (req) => {
|
app.get("/site-access/status", async (req) => {
|
||||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
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 };
|
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) => {
|
app.addHook("preSerialization", (_req, _reply, payload, done) => {
|
||||||
try {
|
try {
|
||||||
if (payload && typeof payload === "object") {
|
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 };
|
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 -----
|
// ----- Dashboard -----
|
||||||
app.get("/dashboard", async (req) => {
|
app.get("/dashboard", async (req) => {
|
||||||
const userId = req.userId;
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
docs/api-phase1-move-log.md
Normal file
61
docs/api-phase1-move-log.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# API Phase 1 Move Log
|
||||||
|
|
||||||
|
Date: 2026-03-15
|
||||||
|
Scope: Move low-risk endpoints out of `api/src/server.ts` into dedicated route modules.
|
||||||
|
|
||||||
|
## Route Registration Changes
|
||||||
|
- Registered session routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1518)
|
||||||
|
- Registered user routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1523)
|
||||||
|
- Registered health routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1524)
|
||||||
|
|
||||||
|
## Endpoint Movements
|
||||||
|
|
||||||
|
1. `GET /health`
|
||||||
|
- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:11)
|
||||||
|
- References:
|
||||||
|
- [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:10)
|
||||||
|
- [security-misconfiguration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-misconfiguration.test.ts:29)
|
||||||
|
|
||||||
|
2. `GET /health/db` (non-production only)
|
||||||
|
- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:14)
|
||||||
|
- References:
|
||||||
|
- [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:15)
|
||||||
|
|
||||||
|
3. `GET /auth/session`
|
||||||
|
- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:40)
|
||||||
|
- References:
|
||||||
|
- [useAuthSession.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useAuthSession.ts:23)
|
||||||
|
- [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:120)
|
||||||
|
|
||||||
|
4. `POST /app/update-notice/ack`
|
||||||
|
- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:77)
|
||||||
|
- References:
|
||||||
|
- [App.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/App.tsx:33)
|
||||||
|
|
||||||
|
5. `PATCH /me`
|
||||||
|
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:50)
|
||||||
|
- References:
|
||||||
|
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:394)
|
||||||
|
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:115)
|
||||||
|
|
||||||
|
6. `PATCH /me/password`
|
||||||
|
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:71)
|
||||||
|
- References:
|
||||||
|
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:148)
|
||||||
|
- [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:57)
|
||||||
|
|
||||||
|
7. `PATCH /me/income-frequency`
|
||||||
|
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:112)
|
||||||
|
- References:
|
||||||
|
- Currently no frontend direct calls found by static search.
|
||||||
|
|
||||||
|
8. `PATCH /user/config`
|
||||||
|
- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:135)
|
||||||
|
- References:
|
||||||
|
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:408)
|
||||||
|
- [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:171)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `server.ts` endpoint blocks for the above routes were removed to prevent duplicate registration.
|
||||||
|
- Existing path contracts were preserved (same method + path).
|
||||||
|
- `openPaths` and auth/CSRF hook behavior remain unchanged.
|
||||||
Reference in New Issue
Block a user