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;