217 lines
6.8 KiB
TypeScript
217 lines
6.8 KiB
TypeScript
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;
|