import { FastifyInstance } from "fastify"; import { attendance_leave_type, leave_requests_leave_type, leave_requests_status, } from "@prisma/client"; import prisma from "../../config/database"; import { requireAuth, requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema, } from "../../schemas/leave-requests.schema"; import { notifyNewLeaveRequest } from "../../services/leave-notification"; const VALID_LEAVE_TYPES = ["vacation", "sick", "unpaid"] as const; const VALID_REVIEW_STATUSES = ["approved", "rejected"] as const; export default async function leaveRequestsRoutes( fastify: FastifyInstance, ): Promise { fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, order } = parsePagination(query); const authData = request.authData!; const isAdmin = authData.permissions.includes("attendance.approve"); const where: Record = {}; if (!isAdmin || query.mine === "1") where.user_id = authData.userId; else if (query.user_id) where.user_id = Number(query.user_id); if (query.status) where.status = String(query.status); const [requests, total] = await Promise.all([ prisma.leave_requests.findMany({ where, skip, take: limit, orderBy: { created_at: order }, include: { users_leave_requests_user_idTousers: { select: { id: true, first_name: true, last_name: true }, }, users_leave_requests_reviewer_idTousers: { select: { id: true, first_name: true, last_name: true }, }, }, }), prisma.leave_requests.count({ where }), ]); return reply.send({ success: true, data: requests, pagination: buildPaginationMeta(total, page, limit), }); }); fastify.post("/", { preHandler: requireAuth }, async (request, reply) => { const parsed = parseBody(CreateLeaveRequestSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const authData = request.authData!; const leaveType = body.leave_type; if ( !VALID_LEAVE_TYPES.includes( leaveType as (typeof VALID_LEAVE_TYPES)[number], ) ) { return error(reply, "Neplatný typ nepřítomnosti", 400); } const dateFrom = new Date(body.date_from); const dateTo = new Date(body.date_to); if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) { return error(reply, "Neplatné datum", 400); } if (dateTo < dateFrom) { return error(reply, "Datum do musí být po datu od", 400); } // Compute business days server-side (matching PHP logic) let businessDays = 0; const current = new Date(dateFrom); while (current <= dateTo) { const day = current.getDay(); if (day !== 0 && day !== 6) businessDays++; current.setDate(current.getDate() + 1); } if (businessDays === 0) { return error(reply, "Zvolený rozsah neobsahuje žádné pracovní dny", 400); } const leaveRequest = await prisma.leave_requests.create({ data: { user_id: authData.userId, leave_type: leaveType as leave_requests_leave_type, date_from: dateFrom, date_to: dateTo, total_hours: businessDays * 8, total_days: businessDays, notes: body.notes ? String(body.notes) : null, status: "pending", }, }); await logAudit({ request, authData, action: "create", entityType: "leave_request", entityId: leaveRequest.id, description: `Vytvořena žádost o nepřítomnost`, }); // Send email notification (non-blocking) try { const employeeName = `${authData.firstName} ${authData.lastName}`.trim() || authData.username; notifyNewLeaveRequest( { leave_type: leaveType, date_from: body.date_from, date_to: body.date_to, total_days: businessDays, total_hours: businessDays * 8, notes: body.notes, }, employeeName, ).catch((err) => request.log.error(err, "Leave notification error")); } catch (err) { request.log.error(err, "Leave notification error"); } return success( reply, { id: leaveRequest.id }, 201, "Žádost byla odeslána ke schválení", ); }); // PUT /api/admin/leave-requests/:id (approve/reject) fastify.put<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("attendance.approve") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(ReviewLeaveRequestSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const authData = request.authData!; const status = body.status; if ( !VALID_REVIEW_STATUSES.includes( status as (typeof VALID_REVIEW_STATUSES)[number], ) ) { return error(reply, "Neplatný stav", 400); } const existing = await prisma.leave_requests.findUnique({ where: { id }, }); if (!existing) return error(reply, "Žádost nenalezena", 404); if (existing.status !== "pending") { return error( reply, "Lze schválit/zamítnout pouze čekající žádosti", 400, ); } if (status === "approved") { // --- APPROVAL: create attendance records + update leave balance (matching PHP) --- const leaveType = existing.leave_type as string; const dateFrom = new Date(existing.date_from); const dateTo = new Date(existing.date_to); // For vacation: re-check balance at approval time if (leaveType === "vacation") { const year = dateFrom.getFullYear(); const balance = await prisma.leave_balances.findFirst({ where: { user_id: existing.user_id, year }, }); const vacTotal = balance ? Number(balance.vacation_total) : 160; const vacUsed = balance ? Number(balance.vacation_used) : 0; const vacRemaining = vacTotal - vacUsed; const totalHours = Number(existing.total_hours) || 0; if (totalHours > vacRemaining) { return error( reply, `Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`, 400, ); } } let totalBusinessDays = 0; const current = new Date(dateFrom); const attendanceCreates: Array<{ user_id: number; shift_date: Date; leave_type: attendance_leave_type; leave_hours: number; notes: string; }> = []; while (current <= dateTo) { const dow = current.getDay(); if (dow !== 0 && dow !== 6) { totalBusinessDays++; attendanceCreates.push({ user_id: existing.user_id, shift_date: new Date( Date.UTC( current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0, ), ), leave_type: leaveType as attendance_leave_type, leave_hours: 8, notes: `Schválená žádost #${id}`, }); } current.setDate(current.getDate() + 1); } const totalHours = totalBusinessDays * 8; await prisma.$transaction(async (tx) => { // 1. Create attendance records for each business day if (attendanceCreates.length > 0) { await tx.attendance.createMany({ data: attendanceCreates }); } // 2. Update leave balance (vacation/sick only — not unpaid) if (leaveType === "vacation" || leaveType === "sick") { const year = dateFrom.getFullYear(); const existingBalance = await tx.leave_balances.findFirst({ where: { user_id: existing.user_id, year }, }); if (existingBalance) { const updateData: Record = { updated_at: new Date(), }; if (leaveType === "vacation") { updateData.vacation_used = Number(existingBalance.vacation_used) + totalHours; } else { updateData.sick_used = Number(existingBalance.sick_used) + totalHours; } await tx.leave_balances.update({ where: { id: existingBalance.id }, data: updateData, }); } else { await tx.leave_balances.create({ data: { user_id: existing.user_id, year, vacation_total: 160, vacation_used: leaveType === "vacation" ? totalHours : 0, sick_used: leaveType === "sick" ? totalHours : 0, }, }); } } // 3. Update request status await tx.leave_requests.update({ where: { id }, data: { status: "approved" as leave_requests_status, reviewer_id: authData.userId, reviewed_at: new Date(), }, }); }); await logAudit({ request, authData, action: "update", entityType: "leave_request", entityId: id, description: `Žádost schválena — vytvořeno ${totalBusinessDays} záznamů (${totalHours}h)`, }); return success(reply, { id }, 200, "Žádost byla schválena"); } // --- REJECTION: just update status --- await prisma.leave_requests.update({ where: { id }, data: { status: "rejected" as leave_requests_status, reviewer_id: authData.userId, reviewer_note: body.reviewer_note ? String(body.reviewer_note) : null, reviewed_at: new Date(), }, }); await logAudit({ request, authData, action: "update", entityType: "leave_request", entityId: id, description: "Žádost zamítnuta", }); return success(reply, { id }, 200, "Žádost byla zamítnuta"); }, ); fastify.delete<{ Params: { id: string } }>( "/:id", { preHandler: requireAuth }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const existing = await prisma.leave_requests.findUnique({ where: { id }, }); if (!existing) return error(reply, "Žádost nenalezena", 404); if (existing.status !== "pending") { return error(reply, "Lze zrušit pouze čekající žádosti", 400); } await prisma.leave_requests.update({ where: { id }, data: { status: "cancelled" }, }); await logAudit({ request, authData: request.authData, action: "update", entityType: "leave_request", entityId: id, description: `Žádost zrušena`, }); return success(reply, null, 200, "Žádost zrušena"); }, ); }