feat: add Zod validation schemas for all domain routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:57:38 +01:00
parent a4303b0188
commit d2b22e9399
32 changed files with 819 additions and 140 deletions

View File

@@ -5,6 +5,18 @@ 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 {
AttendanceNotesSchema,
AttendanceUpdateAddressSchema,
AttendanceSwitchProjectSchema,
AttendanceBalancesSchema,
AttendanceBulkSchema,
AttendanceLeaveSchema,
AttendancePunchSchema,
CreateAttendanceSchema,
UpdateAttendanceSchema,
} from '../../schemas/attendance.schema';
const VALID_LEAVE_TYPES = ['work', 'vacation', 'sick', 'holiday', 'unpaid'] as const;
@@ -175,7 +187,9 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
// POST /api/admin/attendance/notes — save shift notes
fastify.post('/notes', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const body = request.body as Record<string, unknown>;
const parsed = parseBody(AttendanceNotesSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const ongoing = await prisma.attendance.findFirst({
where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } },
@@ -194,9 +208,11 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
// POST /api/admin/attendance/update-address — update GPS address after punch
fastify.post('/update-address', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const body = request.body as Record<string, unknown>;
const addr = body.address ? String(body.address).substring(0, 500) : null;
const action = String(body.punch_action || 'arrival');
const parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const addr = body.address ?? null;
const action = body.punch_action;
const latest = await prisma.attendance.findFirst({
where: { user_id: authData.userId },
@@ -218,7 +234,9 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
// POST /api/admin/attendance/switch-project — switch active project on current shift
fastify.post('/switch-project', { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const body = request.body as Record<string, unknown>;
const parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const ongoing = await prisma.attendance.findFirst({
where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } },
@@ -686,7 +704,7 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
// POST /api/admin/attendance
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
const body = request.body as Record<string, unknown>;
const rawBody = request.body as Record<string, unknown>;
const authData = request.authData!;
const postQuery = request.query as Record<string, unknown>;
@@ -696,25 +714,29 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
return error(reply, 'Nedostatečná oprávnění', 403);
}
const userId = Number(body.user_id);
const yr = Number(body.year) || new Date().getFullYear();
const actionType = String(body.action_type);
const balParsed = parseBody(AttendanceBalancesSchema, rawBody);
if ('error' in balParsed) return error(reply, balParsed.error, 400);
const balBody = balParsed.data;
const userId = balBody.user_id;
const yr = balBody.year || new Date().getFullYear();
const actionType = balBody.action_type;
if (actionType === 'edit') {
await prisma.leave_balances.upsert({
where: { user_id_year: { user_id: userId, year: yr } },
update: {
vacation_total: body.vacation_total != null ? Number(body.vacation_total) : undefined,
vacation_used: body.vacation_used != null ? Number(body.vacation_used) : undefined,
sick_used: body.sick_used != null ? Number(body.sick_used) : undefined,
vacation_total: balBody.vacation_total != null ? Number(balBody.vacation_total) : undefined,
vacation_used: balBody.vacation_used != null ? Number(balBody.vacation_used) : undefined,
sick_used: balBody.sick_used != null ? Number(balBody.sick_used) : undefined,
updated_at: new Date(),
},
create: {
user_id: userId,
year: yr,
vacation_total: Number(body.vacation_total) || 160,
vacation_used: Number(body.vacation_used) || 0,
sick_used: Number(body.sick_used) || 0,
vacation_total: Number(balBody.vacation_total) || 160,
vacation_used: Number(balBody.vacation_used) || 0,
sick_used: Number(balBody.sick_used) || 0,
},
});
@@ -748,19 +770,16 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
return error(reply, 'Nedostatečná oprávnění', 403);
}
const monthStr = String(body.month || '');
const userIds = (body.user_ids as number[]) || [];
const arrivalTime = String(body.arrival_time || '08:00');
const departureTime = String(body.departure_time || '16:30');
const breakStartTime = String(body.break_start_time || '12:00');
const breakEndTime = String(body.break_end_time || '12:30');
const bulkParsed = parseBody(AttendanceBulkSchema, rawBody);
if ('error' in bulkParsed) return error(reply, bulkParsed.error, 400);
const bulkBody = bulkParsed.data;
if (!monthStr || !/^\d{4}-\d{2}$/.test(monthStr)) {
return error(reply, 'Měsíc je povinný (formát YYYY-MM)', 400);
}
if (!userIds.length) {
return error(reply, 'Vyberte alespoň jednoho zaměstnance', 400);
}
const monthStr = bulkBody.month;
const userIds = bulkBody.user_ids;
const arrivalTime = bulkBody.arrival_time;
const departureTime = bulkBody.departure_time;
const breakStartTime = bulkBody.break_start_time;
const breakEndTime = bulkBody.break_end_time;
const [yrStr, moStr] = monthStr.split('-');
const yr = Number(yrStr);
@@ -821,10 +840,13 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
// --- action=leave: add leave record directly ---
if (postQuery.action === 'leave') {
const userId = body.user_id ? Number(body.user_id) : authData.userId;
const dateFrom = String(body.date_from || '');
const dateTo = String(body.date_to || dateFrom);
const leaveTypeStr = String(body.leave_type || 'vacation');
const leaveParsed = parseBody(AttendanceLeaveSchema, rawBody);
if ('error' in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data;
const userId = leaveBody.user_id ?? authData.userId;
const dateFrom = leaveBody.date_from;
const dateTo = leaveBody.date_to || dateFrom;
const leaveTypeStr = leaveBody.leave_type;
if (!VALID_LEAVE_TYPES.includes(leaveTypeStr as typeof VALID_LEAVE_TYPES[number])) {
return error(reply, 'Neplatný typ nepřítomnosti', 400);
}
@@ -847,8 +869,8 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: body.leave_hours ? Number(body.leave_hours) : 8,
notes: body.notes ? String(body.notes) : null,
leave_hours: leaveBody.leave_hours ? Number(leaveBody.leave_hours) : 8,
notes: leaveBody.notes ? String(leaveBody.notes) : null,
},
});
created++;
@@ -857,7 +879,7 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
}
// Update leave balance for vacation/sick (matching PHP updateLeaveBalance)
const totalLeaveHours = created * (body.leave_hours ? Number(body.leave_hours) : 8);
const totalLeaveHours = created * (leaveBody.leave_hours ? Number(leaveBody.leave_hours) : 8);
if ((leaveType === 'vacation' || leaveType === 'sick') && totalLeaveHours > 0) {
const year = new Date(dateFrom).getFullYear();
const existingBalance = await prisma.leave_balances.findFirst({
@@ -886,17 +908,20 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
}
// Punch action (arrival / departure / break_start) from Dashboard or Attendance page
if (body.punch_action) {
const action = String(body.punch_action);
if (rawBody.punch_action) {
const punchParsed = parseBody(AttendancePunchSchema, rawBody);
if ('error' in punchParsed) return error(reply, punchParsed.error, 400);
const punchBody = punchParsed.data;
const action = punchBody.punch_action;
const now = new Date();
// Use noon UTC to avoid timezone shift issues with Prisma/MySQL DATE columns
const y = now.getFullYear(), m = now.getMonth(), d = now.getDate();
const today = new Date(Date.UTC(y, m, d, 12, 0, 0));
const gpsLat = body.latitude != null && body.latitude !== '' ? Number(body.latitude) : null;
const gpsLng = body.longitude != null && body.longitude !== '' ? Number(body.longitude) : null;
const gpsAcc = body.accuracy != null && body.accuracy !== '' ? Number(body.accuracy) : null;
const gpsAddr = body.address ? String(body.address).substring(0, 500) : null;
const gpsLat = punchBody.latitude != null && punchBody.latitude !== '' ? Number(punchBody.latitude) : null;
const gpsLng = punchBody.longitude != null && punchBody.longitude !== '' ? Number(punchBody.longitude) : null;
const gpsAcc = punchBody.accuracy != null && punchBody.accuracy !== '' ? Number(punchBody.accuracy) : null;
const gpsAddr = punchBody.address ?? null;
// Round arrival UP to nearest 15 min, departure DOWN
const roundUp15 = (d: Date) => {
@@ -1012,30 +1037,34 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
}
// Standard attendance record creation (from admin forms)
const stdParsed = parseBody(CreateAttendanceSchema, rawBody);
if ('error' in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data;
const record = await prisma.attendance.create({
data: {
user_id: body.user_id ? Number(body.user_id) : authData.userId,
shift_date: new Date(String(body.shift_date)),
arrival_time: body.arrival_time ? new Date(String(body.arrival_time)) : null,
arrival_lat: body.arrival_lat ? Number(body.arrival_lat) : null,
arrival_lng: body.arrival_lng ? Number(body.arrival_lng) : null,
arrival_accuracy: body.arrival_accuracy ? Number(body.arrival_accuracy) : null,
arrival_address: body.arrival_address ? String(body.arrival_address) : null,
departure_time: body.departure_time ? new Date(String(body.departure_time)) : null,
departure_lat: body.departure_lat ? Number(body.departure_lat) : null,
departure_lng: body.departure_lng ? Number(body.departure_lng) : null,
departure_accuracy: body.departure_accuracy ? Number(body.departure_accuracy) : null,
departure_address: body.departure_address ? String(body.departure_address) : null,
notes: body.notes ? String(body.notes) : null,
project_id: body.project_id ? Number(body.project_id) : null,
leave_type: (body.leave_type ? String(body.leave_type) : 'work') as attendance_leave_type,
leave_hours: body.leave_hours ? Number(body.leave_hours) : null,
user_id: body.user_id ?? authData.userId,
shift_date: new Date(body.shift_date),
arrival_time: body.arrival_time ? new Date(body.arrival_time) : null,
arrival_lat: body.arrival_lat ?? null,
arrival_lng: body.arrival_lng ?? null,
arrival_accuracy: body.arrival_accuracy ?? null,
arrival_address: body.arrival_address ?? null,
departure_time: body.departure_time ? new Date(body.departure_time) : null,
departure_lat: body.departure_lat ?? null,
departure_lng: body.departure_lng ?? null,
departure_accuracy: body.departure_accuracy ?? null,
departure_address: body.departure_address ?? null,
notes: body.notes ?? null,
project_id: body.project_id ?? null,
leave_type: body.leave_type as attendance_leave_type,
leave_hours: body.leave_hours ?? null,
},
});
// Save project logs if provided
if (Array.isArray(body.project_logs)) {
const logs = (body.project_logs as Array<Record<string, unknown>>)
const logs = body.project_logs
.filter(l => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0));
if (logs.length > 0) {
await prisma.attendance_project_logs.createMany({
@@ -1065,7 +1094,9 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const body = request.body as Record<string, unknown>;
const parsed = parseBody(UpdateAttendanceSchema, request.body);
if ('error' in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.attendance.findUnique({ where: { id } });
if (!existing) return error(reply, 'Záznam nenalezen', 404);
@@ -1096,7 +1127,7 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
// Delete existing logs for this record
await prisma.attendance_project_logs.deleteMany({ where: { attendance_id: id } });
// Insert new ones (skip entries with no project_id or zero time)
const logs = (body.project_logs as Array<Record<string, unknown>>)
const logs = body.project_logs
.filter(l => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0));
if (logs.length > 0) {
await prisma.attendance_project_logs.createMany({