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) 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); } } // Count business days and create attendance records 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; // Run everything in a transaction 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'); }); }