Files
app/src/routes/admin/leave-requests.ts
BOHA 2402b7cbc8 fix: "Moje žádosti" page shows only current user's requests
Admins were seeing all requests on their own requests page.
Added mine=1 param to force user_id filter regardless of role.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:03:05 +01:00

361 lines
12 KiB
TypeScript

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<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const isAdmin = authData.permissions.includes("attendance.approve");
const where: Record<string, unknown> = {};
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<string, unknown> = {
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");
},
);
}