const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { currentDateString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { buildAttendanceMetrics, buildAttendanceValidationError, buildMyMonthAttendance, buildTeamAttendance, detectAttendanceSource, getAttendancePolicy, getExemptionForDate, loadAttendanceExemptions, loadAttendanceRecord, mapAttendanceRecord, parseTimeToMinutes, upsertAttendancePolicy, } = require("../services/attendance"); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime()); const CLOCK_BODY_SCHEMA = z .object({ note: z.string().trim().max(300).nullable().optional(), }) .strict(); const MONTH_QUERY_SCHEMA = z.object({ year: z.coerce.number().int().min(2000).max(2100).optional(), month: z.coerce.number().int().min(1).max(12).optional(), }); const TEAM_QUERY_SCHEMA = z.object({ date: DATE_SCHEMA.optional(), departmentId: z.string().uuid().optional(), }); const POLICY_BODY_SCHEMA = z .object({ workStartTime: z.string().trim(), workEndTime: z.string().trim(), lunchStartTime: z.string().trim(), lunchEndTime: z.string().trim(), lateToleranceMinutes: z.coerce.number().int().min(0).max(600), enabled: z.boolean().optional(), }) .strict() .superRefine((value, ctx) => { try { parseTimeToMinutes(value.workStartTime, "workStartTime"); parseTimeToMinutes(value.workEndTime, "workEndTime"); parseTimeToMinutes(value.lunchStartTime, "lunchStartTime"); parseTimeToMinutes(value.lunchEndTime, "lunchEndTime"); } catch (error) { if (error instanceof AppError) { for (const [key, reason] of Object.entries(error.details ?? {})) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: String(reason), }); } return; } throw error; } }); const ADMIN_BACKFILL_BODY_SCHEMA = z .object({ employeeId: z.string().uuid(), workDate: DATE_SCHEMA, clockInAt: DATE_TIME_SCHEMA.nullable().optional(), clockOutAt: DATE_TIME_SCHEMA.nullable().optional(), note: z.string().trim().max(300).nullable().optional(), }) .strict(); function parseInput(schema, value) { const parsed = schema.safeParse(value ?? {}); if (!parsed.success) { throw buildAttendanceValidationError(parsed.error.flatten()); } return parsed.data; } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.attendance, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildAttendanceValidationError( { featureFlag: "attendance_v1_disabled", reason: gate.reason }, 409, ); } } async function loadEmployeeForAttendance(prisma, employeeId) { return prisma.employee.findUnique({ where: { id: employeeId }, select: { id: true, status: true, department_id: true, legal_entity_id: true, name: true, }, }); } async function resolveTodayState(fastify, employeeId) { const workDate = currentDateString(fastify.config.appTimezone); const employee = await loadEmployeeForAttendance(fastify.prisma, employeeId); if (!employee || employee.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const [record, exemptions] = await Promise.all([ loadAttendanceRecord(fastify.prisma, employeeId, workDate), loadAttendanceExemptions(fastify.prisma, { employees: [{ id: employee.id, legal_entity_id: employee.legal_entity_id }], dateStrings: [workDate], }), ]); const exemption = getExemptionForDate(exemptions, employeeId, workDate); return { employee, workDate, record, exemption, }; } function parseOptionalDateTime(value) { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { throw buildAttendanceValidationError({ datetime: "invalid" }); } return date; } async function attendanceRoutes(fastify) { fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(CLOCK_BODY_SCHEMA, request.body); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); if (record?.clock_in_at) { return sendData(reply, request, { item: mapAttendanceRecord(record) }); } if (exemption) { throw buildAttendanceValidationError( { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, 409, ); } const policy = await getAttendancePolicy(fastify.prisma); const clockInAt = new Date(); const clockOutAt = record?.clock_out_at && record.clock_out_at > clockInAt ? record.clock_out_at : null; const nextNote = body.note || record?.note || null; const metrics = buildAttendanceMetrics({ clockInAt, clockOutAt, workDate, timeZone: fastify.config.appTimezone, policy, }); const saved = record ? await fastify.prisma.attendanceRecord.update({ where: { id: record.id }, data: { clock_in_at: clockInAt, clock_out_at: clockOutAt, work_minutes: metrics.workMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, note: nextNote, source: detectAttendanceSource(request), updated_by: request.employee.id, }, }) : await fastify.prisma.attendanceRecord.create({ data: { employee_id: request.employee.id, work_date: new Date(`${workDate}T00:00:00.000Z`), clock_in_at: clockInAt, clock_out_at: null, work_minutes: null, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: false, early_minutes: 0, note: body.note || null, source: detectAttendanceSource(request), updated_by: request.employee.id, }, }); return sendData(reply, request, { item: mapAttendanceRecord(saved) }); }); fastify.post("/attendance/clock-out", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(CLOCK_BODY_SCHEMA, request.body); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); if (!record?.clock_in_at) { if (exemption) { throw buildAttendanceValidationError( { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, 409, ); } throw buildAttendanceValidationError({ attendance: "missing_clock_in" }); } const clockOutAt = new Date(); const policy = await getAttendancePolicy(fastify.prisma); const nextNote = body.note || record.note || null; const metrics = buildAttendanceMetrics({ clockInAt: record.clock_in_at, clockOutAt, workDate, timeZone: fastify.config.appTimezone, policy, }); const saved = await fastify.prisma.attendanceRecord.update({ where: { id: record.id }, data: { clock_out_at: clockOutAt, work_minutes: metrics.workMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, note: nextNote, source: detectAttendanceSource(request), updated_by: request.employee.id, }, }); return sendData(reply, request, { item: mapAttendanceRecord(saved) }); }); fastify.get("/attendance/me/today", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); return sendData(reply, request, { workDate, needClockIn: !exemption, exemptionReason: exemption?.exemptionReason ?? null, item: mapAttendanceRecord(record), }); }); fastify.get("/attendance/me/month", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const query = parseInput(MONTH_QUERY_SCHEMA, request.query); const now = new Date(); const parts = new Intl.DateTimeFormat("en-CA", { timeZone: fastify.config.appTimezone, year: "numeric", month: "2-digit", }).formatToParts(now); const partMap = {}; for (const part of parts) { if (part.type !== "literal") { partMap[part.type] = part.value; } } const payload = await buildMyMonthAttendance(fastify.prisma, { employeeId: request.employee.id, timeZone: fastify.config.appTimezone, year: query.year ?? Number(partMap.year), month: query.month ?? Number(partMap.month), }); return sendData(reply, request, payload); }); fastify.get( "/attendance/team", { preHandler: fastify.requirePermission("system.users") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const query = parseInput(TEAM_QUERY_SCHEMA, request.query); const dateString = query.date ?? currentDateString(fastify.config.appTimezone); const items = await buildTeamAttendance(fastify.prisma, { dateString, departmentId: query.departmentId, timeZone: fastify.config.appTimezone, }); return sendData(reply, request, { date: dateString, items, }); }, ); fastify.get("/attendance/policy", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const item = await getAttendancePolicy(fastify.prisma); return sendData(reply, request, { item }); }); fastify.put( "/attendance/policy", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(POLICY_BODY_SCHEMA, request.body); const item = await upsertAttendancePolicy(fastify.prisma, request.employee.id, body); return sendData(reply, request, { item }); }, ); fastify.post( "/attendance/admin/backfill", { preHandler: fastify.requirePermission("system.users") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(ADMIN_BACKFILL_BODY_SCHEMA, request.body); const employee = await loadEmployeeForAttendance(fastify.prisma, body.employeeId); if (!employee || employee.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const before = await loadAttendanceRecord(fastify.prisma, body.employeeId, body.workDate); const nextClockInAt = body.clockInAt !== undefined ? parseOptionalDateTime(body.clockInAt) : before?.clock_in_at ?? null; const nextClockOutAt = body.clockOutAt !== undefined ? parseOptionalDateTime(body.clockOutAt) : before?.clock_out_at ?? null; if (!nextClockInAt && !nextClockOutAt) { throw buildAttendanceValidationError({ attendance: "missing_backfill_values" }); } if (nextClockInAt && nextClockOutAt && nextClockOutAt < nextClockInAt) { throw buildAttendanceValidationError({ clockOutAt: "must_be_after_or_equal_clockInAt" }); } const policy = await getAttendancePolicy(fastify.prisma); const nextNote = body.note || before?.note || null; const metrics = buildAttendanceMetrics({ clockInAt: nextClockInAt, clockOutAt: nextClockOutAt, workDate: body.workDate, timeZone: fastify.config.appTimezone, policy, }); const saved = before ? await fastify.prisma.attendanceRecord.update({ where: { id: before.id }, data: { clock_in_at: nextClockInAt, clock_out_at: nextClockOutAt, work_minutes: metrics.workMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, note: nextNote, source: "admin", updated_by: request.employee.id, }, }) : await fastify.prisma.attendanceRecord.create({ data: { employee_id: body.employeeId, work_date: new Date(`${body.workDate}T00:00:00.000Z`), clock_in_at: nextClockInAt, clock_out_at: nextClockOutAt, work_minutes: metrics.workMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, note: nextNote, source: "admin", updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ATTENDANCE_ADMIN_BACKFILLED", targetType: "attendance_record", targetId: saved.id, extra: { employeeId: body.employeeId, workDate: body.workDate, note: nextNote, before: mapAttendanceRecord(before), after: mapAttendanceRecord(saved), }, }), }); return sendData(reply, request, { item: mapAttendanceRecord(saved) }); }, ); } attendanceRoutes.__testables = { ensureFeatureEnabled, parseOptionalDateTime, resolveTodayState, }; module.exports = attendanceRoutes;