style: run prettier on entire codebase
This commit is contained in:
@@ -1,23 +1,32 @@
|
||||
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';
|
||||
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;
|
||||
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) => {
|
||||
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 isAdmin = authData.permissions.includes("attendance.approve");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (!isAdmin) where.user_id = authData.userId;
|
||||
@@ -26,37 +35,52 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
|
||||
|
||||
const [requests, total] = await Promise.all([
|
||||
prisma.leave_requests.findMany({
|
||||
where, skip, take: limit, orderBy: { created_at: order },
|
||||
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 } },
|
||||
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) });
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: requests,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateLeaveRequestSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
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);
|
||||
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);
|
||||
return error(reply, "Neplatné datum", 400);
|
||||
}
|
||||
if (dateTo < dateFrom) {
|
||||
return error(reply, 'Datum do musí být po datu od', 400);
|
||||
return error(reply, "Datum do musí být po datu od", 400);
|
||||
}
|
||||
|
||||
// Compute business days server-side (matching PHP logic)
|
||||
@@ -69,7 +93,7 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
|
||||
}
|
||||
|
||||
if (businessDays === 0) {
|
||||
return error(reply, 'Zvolený rozsah neobsahuje žádné pracovní dny', 400);
|
||||
return error(reply, "Zvolený rozsah neobsahuje žádné pracovní dny", 400);
|
||||
}
|
||||
|
||||
const leaveRequest = await prisma.leave_requests.create({
|
||||
@@ -81,177 +105,258 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
|
||||
total_hours: businessDays * 8,
|
||||
total_days: businessDays,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
status: 'pending',
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData, action: 'create', entityType: 'leave_request', entityId: leaveRequest.id, description: `Vytvořena žádost o nepřítomnost` });
|
||||
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'));
|
||||
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');
|
||||
request.log.error(err, "Leave notification error");
|
||||
}
|
||||
|
||||
return success(reply, { id: leaveRequest.id }, 201, 'Žádost byla odeslána ke schválení');
|
||||
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!;
|
||||
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);
|
||||
}
|
||||
const status = body.status;
|
||||
if (
|
||||
!VALID_REVIEW_STATUSES.includes(
|
||||
status as (typeof VALID_REVIEW_STATUSES)[number],
|
||||
)
|
||||
) {
|
||||
return error(reply, "Neplatný stav", 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;
|
||||
}> = [];
|
||||
const existing = await prisma.leave_requests.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) return error(reply, "Žádost nenalezena", 404);
|
||||
|
||||
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);
|
||||
if (existing.status !== "pending") {
|
||||
return error(
|
||||
reply,
|
||||
"Lze schválit/zamítnout pouze čekající žádosti",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
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);
|
||||
|
||||
// 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') {
|
||||
// For vacation: re-check balance at approval time
|
||||
if (leaveType === "vacation") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
const balance = await prisma.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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
},
|
||||
// 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<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 schválena — vytvořeno ${totalBusinessDays} záznamů (${totalHours}h)` });
|
||||
return success(reply, { id }, 200, 'Žádost byla schválena');
|
||||
}
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "leave_request",
|
||||
entityId: id,
|
||||
description: "Žádost zamítnuta",
|
||||
});
|
||||
return success(reply, { id }, 200, "Žádost byla zamítnuta");
|
||||
},
|
||||
);
|
||||
|
||||
// --- 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(),
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
||||
await logAudit({ request, authData, action: 'update', entityType: 'leave_request', entityId: id, description: 'Žádost zamítnuta' });
|
||||
return success(reply, { id }, 200, 'Žádost byla zamítnuta');
|
||||
});
|
||||
if (existing.status !== "pending") {
|
||||
return error(reply, "Lze zrušit pouze čekající žádosti", 400);
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user