diff --git a/drizzle/migrations/0001_add_payroll_drafts.sql b/drizzle/migrations/0001_add_payroll_drafts.sql new file mode 100644 index 00000000..c7aef1d4 --- /dev/null +++ b/drizzle/migrations/0001_add_payroll_drafts.sql @@ -0,0 +1,17 @@ +CREATE TYPE "payroll_draft_status" AS ENUM('active', 'processed', 'cancelled'); +--> statement-breakpoint +CREATE TABLE "payroll_drafts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "status" "payroll_draft_status" DEFAULT 'active' NOT NULL, + "employees_payload" jsonb DEFAULT '[]'::jsonb NOT NULL, + "total_amount" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "payroll_drafts" ADD CONSTRAINT "payroll_drafts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "payroll_drafts_organization_id_idx" ON "payroll_drafts" ("organization_id"); +--> statement-breakpoint +CREATE INDEX "payroll_drafts_status_idx" ON "payroll_drafts" ("status"); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 526529cc..159e87b2 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776872929566, "tag": "0000_perfect_ravenous", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777132800000, + "tag": "0001_add_payroll_drafts", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/api/v1/auth/2fa/verify/route.ts b/src/app/api/v1/auth/2fa/verify/route.ts index 4bcc7ec0..35037cc2 100644 --- a/src/app/api/v1/auth/2fa/verify/route.ts +++ b/src/app/api/v1/auth/2fa/verify/route.ts @@ -46,7 +46,6 @@ import { eq } from "drizzle-orm"; */ export async function POST(req: NextRequest) { try { - const body = await req.json(); const validatedData = VerifyTwoFactorSchema.parse(body); @@ -77,9 +76,9 @@ export async function POST(req: NextRequest) { return ApiResponse.error("User not found", 404); } - const accessToken = AuthUtils.generateToken(user.id, user.email); + const accessToken = await AuthUtils.generateToken(user.id, user.email); - const refreshToken = AuthUtils.generateToken(user.id, user.email); + const refreshToken = await AuthUtils.generateToken(user.id, user.email); return ApiResponse.success( { @@ -99,9 +98,7 @@ export async function POST(req: NextRequest) { 200, ); } catch (error) { - if (error instanceof AppError) { - if (error.statusCode === 403 || error.statusCode === 429) { try { const body = await req.clone().json(); @@ -128,9 +125,7 @@ export async function POST(req: NextRequest) { } } } - } catch { - - } + } catch {} } return ApiResponse.error(error.message, error.statusCode, error.errors); diff --git a/src/app/api/v1/kyb/submit/route.ts b/src/app/api/v1/kyb/submit/route.ts index 58c1cfc1..beb4cfba 100644 --- a/src/app/api/v1/kyb/submit/route.ts +++ b/src/app/api/v1/kyb/submit/route.ts @@ -2,7 +2,10 @@ import { NextRequest } from "next/server"; import { ApiResponse } from "@/server/utils/api-response"; import { AppError, ValidationError } from "@/server/utils/errors"; import { AuthUtils } from "@/server/utils/auth"; -import { KybSubmitSchema, KYB_FILE_CONSTRAINTS } from "@/server/validations/kyb.schema"; +import { + KybSubmitSchema, + KYB_FILE_CONSTRAINTS, +} from "@/server/validations/kyb.schema"; import { KybService } from "@/server/services/kyb.service"; import { KybUploadService } from "@/server/services/kyb-upload.service"; import { ZodError } from "zod"; @@ -72,13 +75,13 @@ export const POST = withKybRateLimit(async (req: NextRequest) => { }); if (!incorporationCertificatePath) { - throw new ValidationError("Incorporation certificate path is required", { + throw new ValidationError("Validation failed", { fieldErrors: { incorporationCertificatePath: "Path is required" }, }); } if (!memorandumArticlePath) { - throw new ValidationError("Memorandum & Article of Association path is required", { + throw new ValidationError("Validation failed", { fieldErrors: { memorandumArticlePath: "Path is required" }, }); } @@ -88,16 +91,25 @@ export const POST = withKybRateLimit(async (req: NextRequest) => { registrationType: validatedFields.registrationType, registrationNo: validatedFields.registrationNo, incorporationCertificatePath: incorporationCertificatePath, - incorporationCertificateUrl: KybUploadService.getPublicUrl(incorporationCertificatePath), + incorporationCertificateUrl: KybUploadService.getPublicUrl( + incorporationCertificatePath, + ), memorandumArticlePath: memorandumArticlePath, - memorandumArticleUrl: KybUploadService.getPublicUrl(memorandumArticlePath), + memorandumArticleUrl: KybUploadService.getPublicUrl( + memorandumArticlePath, + ), formC02C07Path: formC02C07Path ?? null, - formC02C07Url: formC02C07Path ? KybUploadService.getPublicUrl(formC02C07Path) : null, + formC02C07Url: formC02C07Path + ? KybUploadService.getPublicUrl(formC02C07Path) + : null, }); - return ApiResponse.success(result, "KYB documents submitted successfully", 201); + return ApiResponse.success( + result, + "KYB documents submitted successfully", + 201, + ); } catch (error) { - if (error instanceof ZodError) { const fieldErrors: Record = {}; error.issues.forEach((issue: any) => { @@ -114,5 +126,5 @@ export const POST = withKybRateLimit(async (req: NextRequest) => { console.error("[KYB Submit Error]", error); return ApiResponse.error("Internal server error", 500); -} + } }); diff --git a/src/app/api/v1/payroll/[draftId]/adjustments/route.ts b/src/app/api/v1/payroll/[draftId]/adjustments/route.ts new file mode 100644 index 00000000..b6b69954 --- /dev/null +++ b/src/app/api/v1/payroll/[draftId]/adjustments/route.ts @@ -0,0 +1,329 @@ +import { NextRequest } from "next/server"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AuthUtils } from "@/server/utils/auth"; +import { AppError } from "@/server/utils/errors"; +import { db, payrollDrafts, users } from "@/server/db"; + +const AdjustmentSchema = z.object({ + employeeId: z.string().uuid(), + type: z.enum(["bonus", "deduction"]), + amount: z.number().positive(), + action: z.enum(["add", "remove"]).default("add"), + reason: z.string().trim().max(255).optional(), +}); +const DraftParamsSchema = z.object({ + draftId: z.string().uuid(), +}); + +type AdjustmentInput = z.infer; +type AdjustmentType = AdjustmentInput["type"]; + +type PayrollEmployeePayload = Record & { + id?: string; + employeeId?: string; + netPay?: number; + bonus?: number; + bonuses?: number; + deduction?: number; + deductions?: number; + adjustments?: PayrollAdjustment[]; +}; + +type PayrollAdjustment = { + id: string; + type: AdjustmentType; + amount: number; + reason?: string; + createdAt: string; +}; + +function toNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +function getEmployeeId(employee: PayrollEmployeePayload): string | undefined { + if (typeof employee.employeeId === "string") { + return employee.employeeId; + } + + if (typeof employee.id === "string") { + return employee.id; + } + + return undefined; +} + +function getAdjustmentTotal( + employee: PayrollEmployeePayload, + type: AdjustmentType, +): number { + const directValue = + type === "bonus" + ? (employee.bonus ?? employee.bonuses) + : (employee.deduction ?? employee.deductions); + + if (directValue !== undefined && directValue !== null) { + return toNumber(directValue); + } + + return (employee.adjustments ?? []) + .filter((adjustment) => adjustment.type === type) + .reduce((total, adjustment) => total + toNumber(adjustment.amount), 0); +} + +function getBasePay(employee: PayrollEmployeePayload): number { + const knownBasePay = + employee.basePay ?? + employee.grossPay ?? + employee.grossAmount ?? + employee.salary ?? + employee.amount; + + const basePay = toNumber(knownBasePay); + if (basePay > 0) { + return basePay; + } + + return ( + toNumber(employee.netPay) - + getAdjustmentTotal(employee, "bonus") + + getAdjustmentTotal(employee, "deduction") + ); +} + +function applyAdjustment( + employee: PayrollEmployeePayload, + input: AdjustmentInput, +): PayrollEmployeePayload { + const bonusTotal = getAdjustmentTotal(employee, "bonus"); + const deductionTotal = getAdjustmentTotal(employee, "deduction"); + const currentTotal = input.type === "bonus" ? bonusTotal : deductionTotal; + + if (input.action === "remove" && input.amount > currentTotal) { + throw new Error(`Cannot remove more ${input.type} than currently applied`); + } + + const nextBonusTotal = + input.type === "bonus" + ? input.action === "add" + ? bonusTotal + input.amount + : bonusTotal - input.amount + : bonusTotal; + const nextDeductionTotal = + input.type === "deduction" + ? input.action === "add" + ? deductionTotal + input.amount + : deductionTotal - input.amount + : deductionTotal; + + const existingAdjustments = employee.adjustments ?? []; + const adjustments = + input.action === "add" + ? [ + ...existingAdjustments, + { + id: randomUUID(), + type: input.type, + amount: input.amount, + reason: input.reason, + createdAt: new Date().toISOString(), + }, + ] + : existingAdjustments; + + const netPay = getBasePay(employee) + nextBonusTotal - nextDeductionTotal; + + return { + ...employee, + employeeId: getEmployeeId(employee), + bonus: nextBonusTotal, + bonuses: nextBonusTotal, + deduction: nextDeductionTotal, + deductions: nextDeductionTotal, + netPay, + adjustments, + }; +} + +function recalculateDraftTotal(employeesPayload: PayrollEmployeePayload[]) { + return employeesPayload.reduce( + (total, employee) => total + toNumber(employee.netPay), + 0, + ); +} + +async function handleAdjustment( + req: NextRequest, + context: { params: Promise<{ draftId: string }> }, +) { + try { + const { draftId } = await context.params; + const { userId } = await AuthUtils.authenticateRequestOrRefreshCookie(req); + + const parsedParams = DraftParamsSchema.safeParse({ draftId }); + if (!parsedParams.success) { + return ApiResponse.error( + "Invalid payroll draft ID", + 400, + parsedParams.error.flatten().fieldErrors, + req, + ); + } + + const body = await req.json(); + const parsed = AdjustmentSchema.safeParse(body); + + if (!parsed.success) { + return ApiResponse.error( + "Invalid request body", + 400, + parsed.error.flatten().fieldErrors, + req, + ); + } + + const [user] = await db + .select({ organizationId: users.organizationId }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user?.organizationId) { + return ApiResponse.error( + "User is not associated with any organization", + 403, + null, + req, + ); + } + const organizationId = user.organizationId; + + const updatedDraft = await db.transaction(async (tx) => { + const [draft] = await tx + .select({ + id: payrollDrafts.id, + employeesPayload: payrollDrafts.employeesPayload, + }) + .from(payrollDrafts) + .where( + and( + eq(payrollDrafts.id, parsedParams.data.draftId), + eq(payrollDrafts.organizationId, organizationId), + eq(payrollDrafts.status, "active"), + ), + ) + .limit(1); + + if (!draft) { + return null; + } + + const employeesPayload = Array.isArray(draft.employeesPayload) + ? (draft.employeesPayload as PayrollEmployeePayload[]) + : []; + let employeeFound = false; + + const nextEmployeesPayload = employeesPayload.map((employee) => { + if (getEmployeeId(employee) !== parsed.data.employeeId) { + return employee; + } + + employeeFound = true; + return applyAdjustment(employee, parsed.data); + }); + + if (!employeeFound) { + throw new Error("Employee was not found in this payroll draft"); + } + + const totalAmount = recalculateDraftTotal(nextEmployeesPayload); + const [updated] = await tx + .update(payrollDrafts) + .set({ + employeesPayload: nextEmployeesPayload, + totalAmount, + updatedAt: new Date(), + }) + .where(eq(payrollDrafts.id, draft.id)) + .returning(); + + return updated; + }); + + if (!updatedDraft) { + return ApiResponse.error( + "Active payroll draft not found", + 404, + null, + req, + ); + } + + return ApiResponse.success( + updatedDraft, + "Payroll adjustment applied successfully", + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error( + error.message, + error.statusCode, + error.errors, + req, + ); + } + + if ( + error instanceof Error && + (error.message.includes("Employee was not found") || + error.message.includes("Cannot remove more")) + ) { + return ApiResponse.error(error.message, 400, null, req); + } + + console.error("[Payroll Adjustments Error]", error); + return ApiResponse.error("Internal server error", 500, null, req); + } +} + +/** + * @swagger + * /payroll/{draftId}/adjustments: + * post: + * summary: Apply a payroll draft adjustment + * description: Adds a one-time bonus or custom deduction to an employee in an active payroll draft. + * tags: [Payroll] + */ +export async function POST( + req: NextRequest, + context: { params: Promise<{ draftId: string }> }, +) { + return handleAdjustment(req, context); +} + +/** + * @swagger + * /payroll/{draftId}/adjustments: + * patch: + * summary: Add or remove a payroll draft adjustment + * description: Adds or removes a one-time bonus or custom deduction from an employee in an active payroll draft. + * tags: [Payroll] + */ +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ draftId: string }> }, +) { + return handleAdjustment(req, context); +} diff --git a/src/app/api/v1/payroll/[draftId]/execute/route.ts b/src/app/api/v1/payroll/[draftId]/execute/route.ts new file mode 100644 index 00000000..3cf45644 --- /dev/null +++ b/src/app/api/v1/payroll/[draftId]/execute/route.ts @@ -0,0 +1,178 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; +import { AuthUtils } from "@/server/utils/auth"; +import { db, payrolls, fiatTransactions } from "@/server/db"; +import { eq, and } from "drizzle-orm"; +import { users } from "@/server/db/schema"; +import { z } from "zod"; + +const ExecuteDraftSchema = z.object({ + providerId: z.enum(["monnify", "flutterwave"]).default("monnify"), +}); + +/** + * @swagger + * /payroll/{draftId}/execute: + * post: + * summary: Finalize a draft payroll + * description: Finalizes a draft payroll, locks it, and queues fiat payout transactions. + * tags: [Payroll] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: draftId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the payroll draft to finalize + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * providerId: + * type: string + * enum: [monnify, flutterwave] + * default: monnify + * responses: + * 200: + * description: Payroll finalized successfully + * 400: + * description: Invalid request or validation failed + * 404: + * description: Draft payroll not found + * 500: + * description: Internal server error + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ draftId: string }> } | { params: { draftId: string } } +) { + try { + const { userId } = await AuthUtils.authenticateRequestOrRefreshCookie(req); + + const [user] = await db + .select({ organizationId: users.organizationId }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user?.organizationId) { + return ApiResponse.error("User is not associated with any organization", 403); + } + + const resolvedParams = await params; + const { draftId } = resolvedParams; + + if (!draftId) { + return ApiResponse.error("Draft ID is required", 400); + } + + let body = {}; + try { + body = await req.json(); + } catch { + // Body is optional + } + + const parsed = ExecuteDraftSchema.safeParse(body); + if (!parsed.success) { + return ApiResponse.error( + "Invalid request body", + 400, + parsed.error.flatten().fieldErrors, + ); + } + + const providerId = parsed.data.providerId; + + // Retrieve the draft payroll + const [draft] = await db + .select() + .from(payrolls) + .where( + and( + eq(payrolls.id, draftId), + eq(payrolls.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!draft) { + return ApiResponse.error("Draft payroll not found", 404); + } + + if (draft.status !== "draft") { + return ApiResponse.error("Only draft payrolls can be executed", 400); + } + + const totalsData = draft.totals as any; + if (!totalsData || !totalsData.employees || !Array.isArray(totalsData.employees)) { + return ApiResponse.error("Invalid draft data format", 400); + } + + // Verify the draft totals + let calculatedNetPay = 0; + for (const emp of totalsData.employees) { + if (typeof emp.netPay === "number") { + calculatedNetPay += emp.netPay; + } + } + + // Floating point comparison with a small epsilon + const expectedNetPay = totalsData.totals?.netPay || 0; + if (Math.abs(calculatedNetPay - expectedNetPay) > 0.01) { + return ApiResponse.error("Draft totals verification failed", 400); + } + + // Update the payroll status to processing + await db + .update(payrolls) + .set({ status: "processing", updatedAt: new Date() }) + .where(eq(payrolls.id, draftId)); + + // Queue the payout transactions + const transactionsToInsert = totalsData.employees + .filter((emp: any) => typeof emp.netPay === "number" && emp.netPay > 0) + .map((emp: any) => ({ + organizationId: user.organizationId, + amount: Math.round(emp.netPay * 100), // Assuming amount is in smallest currency unit (e.g., kobo/cents) if BigInt + type: "payout" as const, + status: "pending" as const, + provider: providerId, + providerReference: crypto.randomUUID(), // Generate a unique reference + metadata: { + payrollId: draftId, + employeeId: emp.employeeId, + grossPay: emp.grossPay, + deductions: emp.deductions, + }, + })); + + if (transactionsToInsert.length > 0) { + await db.insert(fiatTransactions).values(transactionsToInsert); + } + + // Update the payroll status to completed + await db + .update(payrolls) + .set({ status: "completed", updatedAt: new Date() }) + .where(eq(payrolls.id, draftId)); + + return ApiResponse.success( + null, + "Payroll draft finalized successfully and payouts queued" + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + console.error("[Payroll Execute Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 1bc25314..b564b589 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -99,6 +99,12 @@ export const fiatProviderEnum = pgEnum("fiat_provider", [ "flutterwave", ]); +export const payrollDraftStatusEnum = pgEnum("payroll_draft_status", [ + "active", + "processed", + "cancelled", +]); + export const invitationRoleEnum = pgEnum("invitation_role", [ "admin", "hr_manager", @@ -122,6 +128,13 @@ export const auditEventEnum = pgEnum("audit_event", [ "SECURITY_CHANGE", ]); +export const payrollStatusEnum = pgEnum("payroll_status", [ + "draft", + "processing", + "completed", + "failed", +]); + export const organizations = pgTable("organizations", { id: uuid("id").primaryKey().defaultRandom(), name: varchar("name", { length: 255 }).notNull(), @@ -499,6 +512,27 @@ export const invoices = pgTable( ], ); +export const payrollDrafts = pgTable( + "payroll_drafts", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .references(() => organizations.id, { onDelete: "cascade" }) + .notNull(), + status: payrollDraftStatusEnum("status").default("active").notNull(), + employeesPayload: jsonb("employees_payload") + .default(sql`'[]'::jsonb`) + .notNull(), + totalAmount: integer("total_amount").default(0).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("payroll_drafts_organization_id_idx").on(table.organizationId), + index("payroll_drafts_status_idx").on(table.status), + ], +); + export const milestones = pgTable( "milestones", { @@ -722,3 +756,28 @@ export const signerAudits = pgTable("signer_audits", { index("signer_audits_transaction_hash_idx").on(table.transactionHash), ]); +export const payrolls = pgTable( + "payrolls", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .references(() => organizations.id, { onDelete: "cascade" }) + .notNull(), + status: payrollStatusEnum("status").default("draft").notNull(), + totals: jsonb("totals"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("payrolls_organization_id_idx").on(table.organizationId), + index("payrolls_status_idx").on(table.status), + ] +); + +export const payrollRelations = relations(payrolls, (helpers: any) => ({ + organization: helpers.one(organizations, { + fields: [payrolls.organizationId], + references: [organizations.id], + }), +})); + diff --git a/src/server/services/jwt.service.ts b/src/server/services/jwt.service.ts index 330e899f..ebd27011 100644 --- a/src/server/services/jwt.service.ts +++ b/src/server/services/jwt.service.ts @@ -6,9 +6,7 @@ export interface JWTPayload extends jose.JWTPayload { email: string; } - export class JWTService { - private static normalizeExpiration(expiration: string): string | number { const msMatch = expiration.match(/^(\d+)ms$/); if (!msMatch) { @@ -20,11 +18,17 @@ export class JWTService { } private static get ACCESS_SECRET() { - const secret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || "vestroll-fallback-secret"; + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) { + throw new Error("JWT_ACCESS_SECRET is not configured"); + } return new TextEncoder().encode(secret); } private static get REFRESH_SECRET() { - const secret = process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET || "vestroll-fallback-secret"; + const secret = process.env.JWT_REFRESH_SECRET; + if (!secret) { + throw new Error("JWT_REFRESH_SECRET is not configured"); + } return new TextEncoder().encode(secret); } private static get ACCESS_EXPIRATION() { @@ -34,9 +38,7 @@ export class JWTService { return process.env.JWT_REFRESH_EXPIRATION || "7d"; } - static async generateAccessToken(payload: JWTPayload): Promise { - return await new jose.SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() @@ -44,9 +46,7 @@ export class JWTService { .sign(this.ACCESS_SECRET); } - static async generateRefreshToken(payload: JWTPayload): Promise { - return await new jose.SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() @@ -54,9 +54,7 @@ export class JWTService { .sign(this.REFRESH_SECRET); } - static async verifyAccessToken(token: string): Promise { - try { const { payload } = await jose.jwtVerify(token, this.ACCESS_SECRET); return payload as JWTPayload; @@ -68,9 +66,7 @@ export class JWTService { } } - static async verifyRefreshToken(token: string): Promise { - try { const { payload } = await jose.jwtVerify(token, this.REFRESH_SECRET); return payload as JWTPayload; diff --git a/src/server/services/token-refresh.service.ts b/src/server/services/token-refresh.service.ts index 85ffddf2..dbdfc47d 100644 --- a/src/server/services/token-refresh.service.ts +++ b/src/server/services/token-refresh.service.ts @@ -1,83 +1,111 @@ -import { db } from "../db"; -import { sessions, users } from "../db/schema"; -import { eq } from "drizzle-orm"; -import { JWTService } from "./jwt.service"; -import { PasswordVerificationService } from "./password-verification.service"; -import { - ExpiredTokenError, - SessionNotFoundError, - TokenSessionMismatchError, - InternalAuthError -} from "../utils/auth-errors"; -import { Logger } from "./logger.service"; - -export class TokenRefreshService { - static async refresh(refreshToken: string, _userAgent?: string, ipAddress?: string) { - - let payload; - try { - payload = await JWTService.verifyRefreshToken(refreshToken); - } catch (error) { - Logger.error("Token refresh verification failed", { ipAddress, error: String(error) }); - throw error; - } - - const sessionId = payload.sessionId as string; - if (!sessionId) { - Logger.error("Token refresh session ID missing", { ipAddress }); - throw new TokenSessionMismatchError("Token missing session ID"); - } - - const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); - - if (!session) { - Logger.error("Token refresh session not found", { sessionId, ipAddress }); - throw new SessionNotFoundError(); - } - - const isValid = await PasswordVerificationService.verify(refreshToken, session.refreshTokenHash); - - if (!isValid) { - Logger.error("Token refresh hash mismatch - potential replay attack", { sessionId, ipAddress }); - - await db.delete(sessions).where(eq(sessions.id, sessionId)); - throw new TokenSessionMismatchError("Invalid refresh token"); - } - - if (new Date() > session.expiresAt) { - Logger.error("Token refresh session expired", { sessionId, ipAddress }); - await db.delete(sessions).where(eq(sessions.id, sessionId)); - throw new ExpiredTokenError("Session expired"); - } - - const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1); - if (!user) { - throw new InternalAuthError("User not found"); - } - - const accessToken = await JWTService.generateAccessToken({ - userId: user.id, - email: user.email, - }); - - const newRefreshToken = await JWTService.generateRefreshToken({ - userId: user.id, - email: user.email, - sessionId - }); - - const newRefreshTokenHash = await PasswordVerificationService.hash(newRefreshToken); - - await db.update(sessions).set({ - refreshTokenHash: newRefreshTokenHash, - lastUsedAt: new Date(), - }).where(eq(sessions.id, sessionId)); - - Logger.info("Token refresh successful", { userId: user.id, sessionId }); - - return { - accessToken, - refreshToken: newRefreshToken - }; - } -} +import { db } from "../db"; +import { sessions, users } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { JWTTokenService } from "./jwt-token.service"; +import { JWTVerificationService } from "./jwt-verification.service"; +import { PasswordVerificationService } from "./password-verification.service"; +import { + ExpiredTokenError, + SessionNotFoundError, + TokenSessionMismatchError, + InternalAuthError, +} from "../utils/auth-errors"; +import { Logger } from "./logger.service"; + +export class TokenRefreshService { + static async refresh( + refreshToken: string, + _userAgent?: string, + ipAddress?: string, + ) { + let payload; + try { + payload = await JWTVerificationService.verify(refreshToken); + } catch (error) { + Logger.error("Token refresh verification failed", { + ipAddress, + error: String(error), + }); + throw error; + } + + const sessionId = payload.sessionId as string; + if (!sessionId) { + Logger.error("Token refresh session ID missing", { ipAddress }); + throw new TokenSessionMismatchError("Token missing session ID"); + } + + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (!session) { + Logger.error("Token refresh session not found", { sessionId, ipAddress }); + throw new SessionNotFoundError(); + } + + const isValid = await PasswordVerificationService.verify( + refreshToken, + session.refreshTokenHash, + ); + + if (!isValid) { + Logger.error("Token refresh hash mismatch - potential replay attack", { + sessionId, + ipAddress, + }); + + await db.delete(sessions).where(eq(sessions.id, sessionId)); + throw new TokenSessionMismatchError("Invalid refresh token"); + } + + if (new Date() > session.expiresAt) { + Logger.error("Token refresh session expired", { sessionId, ipAddress }); + await db.delete(sessions).where(eq(sessions.id, sessionId)); + throw new ExpiredTokenError("Session expired"); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.userId)) + .limit(1); + if (!user) { + throw new InternalAuthError("User not found"); + } + + const accessToken = await JWTTokenService.generateAccessToken({ + userId: user.id, + email: user.email, + }); + + const newRefreshToken = await JWTTokenService.generateRotatedRefreshToken( + { + userId: user.id, + email: user.email, + sessionId, + }, + payload.exp as number, + ); + + const newRefreshTokenHash = + await PasswordVerificationService.hash(newRefreshToken); + + await db + .update(sessions) + .set({ + refreshTokenHash: newRefreshTokenHash, + lastUsedAt: new Date(), + }) + .where(eq(sessions.id, sessionId)); + + Logger.info("Token refresh successful", { userId: user.id, sessionId }); + + return { + accessToken, + refreshToken: newRefreshToken, + }; + } +} diff --git a/src/server/utils/with-error-handler.ts b/src/server/utils/with-error-handler.ts index 680d8f2f..66f890c8 100644 --- a/src/server/utils/with-error-handler.ts +++ b/src/server/utils/with-error-handler.ts @@ -17,14 +17,12 @@ export interface HandlerContext { metadata: RequestMetadata; } - export function withHandler( options: | { schema?: z.ZodSchema } | ((req: NextRequest, ctx: any) => Promise), handler?: (req: NextRequest, ctx: HandlerContext) => Promise, ) { - if (typeof options === "function") { return withHandler({}, options); } @@ -36,7 +34,7 @@ export function withHandler( const instance = req?.nextUrl?.pathname ?? "unknown"; const method = req?.method ?? "UNKNOWN"; - Logger.info(`[Request] ${method} ${instance}`); + Logger.info?.(`[Request] ${method} ${instance}`); const metadata: RequestMetadata = { ipAddress: AuthUtils.getClientIp(req), @@ -69,52 +67,51 @@ export function withHandler( const response = await handler!(req, { ...ctx, body, metadata }); response.headers.set("X-Response-Id", responseId); - Logger.info(`[Success] ${method} ${instance}`, { + Logger.info?.(`[Success] ${method} ${instance}`, { responseId, status: response.status, }); return response; - } catch (error) { - const instance = req?.nextUrl?.pathname ?? "unknown"; - const method = req?.method ?? "UNKNOWN"; - const responseId = crypto.randomUUID(); - - if (error instanceof AppError) { - Logger.error(`[App Error] ${method} ${instance}`, { - responseId, - type: error.name, - message: error.message, - errors: error.errors, - }); - const response = ApiResponse.error( - error.message, - error.status, - error.errors, - req, - ) as NextResponse; - response.headers.set("X-Response-Id", responseId); - return response; - } - - Logger.error(`[Unhandled Error] ${method} ${instance}`, { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - responseId, - }); - - const response = ApiResponse.error( - "Internal server error", - 500, - null, - req, - ) as NextResponse; - - response.headers.set("X-Response-Id", responseId); - return response; - } + } catch (error) { + const instance = req?.nextUrl?.pathname ?? "unknown"; + const method = req?.method ?? "UNKNOWN"; + const responseId = crypto.randomUUID(); + + if (error instanceof AppError) { + Logger.error?.(`[App Error] ${method} ${instance}`, { + responseId, + type: error.name, + message: error.message, + errors: error.errors, + }); + const response = ApiResponse.error( + error.message, + error.status, + error.errors, + req, + ) as NextResponse; + response.headers.set("X-Response-Id", responseId); + return response; + } + + Logger.error?.(`[Unhandled Error] ${method} ${instance}`, { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + responseId, + }); + + const response = ApiResponse.error( + "Internal server error", + 500, + null, + req, + ) as NextResponse; + + response.headers.set("X-Response-Id", responseId); + return response; + } }; } - export const withErrorHandler = withHandler; diff --git a/src/server/validations/time-off.schema.ts b/src/server/validations/time-off.schema.ts index 03747f68..a124c243 100644 --- a/src/server/validations/time-off.schema.ts +++ b/src/server/validations/time-off.schema.ts @@ -23,13 +23,15 @@ export const UpdateTimeOffStatusBodySchema = z "Request body for an HR manager or admin to approve or reject a pending time-off request.", ); -export type UpdateTimeOffStatusBody = z.infer; +export type UpdateTimeOffStatusBody = z.infer< + typeof UpdateTimeOffStatusBodySchema +>; export const TimeOffRequestSchema = z .object({ employeeId: z .string() - .uuid("Invalid employee ID") + .min(1, "Invalid employee ID") .optional() .describe( "UUID of the employee submitting the time-off request. Optional when the authenticated user is submitting on their own behalf; required when an admin creates a request on behalf of another employee.",