From d2b22e93992a1249112dcbb253d1607dbdcc26ce Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 08:57:38 +0100 Subject: [PATCH] feat: add Zod validation schemas for all domain routes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/attendance.ts | 149 ++++++++++++++---------- src/routes/admin/bank-accounts.ts | 10 +- src/routes/admin/company-settings.ts | 6 +- src/routes/admin/customers.ts | 13 ++- src/routes/admin/invoices.ts | 10 +- src/routes/admin/leave-requests.ts | 22 ++-- src/routes/admin/orders.ts | 44 ++++--- src/routes/admin/profile.ts | 6 +- src/routes/admin/projects.ts | 14 ++- src/routes/admin/quotations.ts | 10 +- src/routes/admin/received-invoices.ts | 18 +-- src/routes/admin/roles.ts | 10 +- src/routes/admin/scope-templates.ts | 14 ++- src/routes/admin/trips.ts | 10 +- src/routes/admin/users.ts | 30 +++-- src/routes/admin/vehicles.ts | 23 ++-- src/schemas/attendance.schema.ts | 97 +++++++++++++++ src/schemas/bank-accounts.schema.ts | 26 +++++ src/schemas/company-settings.schema.ts | 21 ++++ src/schemas/customers.schema.ts | 28 +++++ src/schemas/invoices.schema.ts | 58 +++++++++ src/schemas/leave-requests.schema.ts | 16 +++ src/schemas/offers.schema.ts | 55 +++++++++ src/schemas/orders.schema.ts | 61 ++++++++++ src/schemas/profile.schema.ts | 11 ++ src/schemas/projects.schema.ts | 35 ++++++ src/schemas/received-invoices.schema.ts | 37 ++++++ src/schemas/roles.schema.ts | 17 +++ src/schemas/scope-templates.schema.ts | 34 ++++++ src/schemas/trips.schema.ts | 26 +++++ src/schemas/users.schema.ts | 24 ++++ src/schemas/vehicles.schema.ts | 24 ++++ 32 files changed, 819 insertions(+), 140 deletions(-) create mode 100644 src/schemas/attendance.schema.ts create mode 100644 src/schemas/bank-accounts.schema.ts create mode 100644 src/schemas/company-settings.schema.ts create mode 100644 src/schemas/customers.schema.ts create mode 100644 src/schemas/invoices.schema.ts create mode 100644 src/schemas/leave-requests.schema.ts create mode 100644 src/schemas/offers.schema.ts create mode 100644 src/schemas/orders.schema.ts create mode 100644 src/schemas/profile.schema.ts create mode 100644 src/schemas/projects.schema.ts create mode 100644 src/schemas/received-invoices.schema.ts create mode 100644 src/schemas/roles.schema.ts create mode 100644 src/schemas/scope-templates.schema.ts create mode 100644 src/schemas/trips.schema.ts create mode 100644 src/schemas/users.schema.ts create mode 100644 src/schemas/vehicles.schema.ts diff --git a/src/routes/admin/attendance.ts b/src/routes/admin/attendance.ts index 86bfd82..6308374 100644 --- a/src/routes/admin/attendance.ts +++ b/src/routes/admin/attendance.ts @@ -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; + 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; - 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; + 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; + const rawBody = request.body as Record; const authData = request.authData!; const postQuery = request.query as Record; @@ -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>) + 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; + 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>) + 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({ diff --git a/src/routes/admin/bank-accounts.ts b/src/routes/admin/bank-accounts.ts index 47657da..c1ea402 100644 --- a/src/routes/admin/bank-accounts.ts +++ b/src/routes/admin/bank-accounts.ts @@ -3,6 +3,8 @@ import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; +import { parseBody } from '../../schemas/common'; +import { CreateBankAccountSchema, UpdateBankAccountSchema } from '../../schemas/bank-accounts.schema'; export default async function bankAccountsRoutes(fastify: FastifyInstance): Promise { fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (_request, reply) => { @@ -11,7 +13,9 @@ export default async function bankAccountsRoutes(fastify: FastifyInstance): Prom }); fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => { - const body = request.body as Record; + const parsed = parseBody(CreateBankAccountSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const account = await prisma.bank_accounts.create({ data: { account_name: body.account_name ? String(body.account_name) : null, @@ -32,7 +36,9 @@ export default async function bankAccountsRoutes(fastify: FastifyInstance): Prom fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateBankAccountSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.bank_accounts.findUnique({ where: { id } }); if (!existing) return error(reply, 'Účet nenalezen', 404); diff --git a/src/routes/admin/company-settings.ts b/src/routes/admin/company-settings.ts index eeebb04..c91be02 100644 --- a/src/routes/admin/company-settings.ts +++ b/src/routes/admin/company-settings.ts @@ -4,6 +4,8 @@ import { requireAuth, requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error } from '../../utils/response'; import multipart from '@fastify/multipart'; +import { parseBody } from '../../schemas/common'; +import { UpdateCompanySettingsSchema } from '../../schemas/company-settings.schema'; /** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */ function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null { @@ -142,7 +144,9 @@ export default async function companySettingsRoutes(fastify: FastifyInstance): P }); fastify.put('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => { - const body = request.body as Record; + const parsed = parseBody(UpdateCompanySettingsSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.company_settings.findFirst(); if (!existing) return error(reply, 'Nastavení nenalezeno', 404); diff --git a/src/routes/admin/customers.ts b/src/routes/admin/customers.ts index 70747cb..4f28f4c 100644 --- a/src/routes/admin/customers.ts +++ b/src/routes/admin/customers.ts @@ -4,6 +4,8 @@ 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 { CreateCustomerSchema, UpdateCustomerSchema } from '../../schemas/customers.schema'; const ALLOWED_SORT_FIELDS = ['id', 'name', 'company_id', 'city', 'country']; @@ -69,10 +71,11 @@ export default async function customersRoutes(fastify: FastifyInstance): Promise }); fastify.post('/', { preHandler: requirePermission('customers.manage') }, async (request, reply) => { - const body = request.body as Record; + const parsed = parseBody(CreateCustomerSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; - const name = body.name ? String(body.name).trim() : ''; - if (!name) return error(reply, 'Název zákazníka je povinný', 400); + const name = body.name; const customer = await prisma.customers.create({ data: { @@ -94,7 +97,9 @@ export default async function customersRoutes(fastify: FastifyInstance): Promise fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateCustomerSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.customers.findUnique({ where: { id } }); if (!existing) return error(reply, 'Zákazník nenalezen', 404); diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 1e168df..7b5f3c2 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -5,6 +5,8 @@ import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { getNextNumber } from '../../utils/sequence'; +import { parseBody } from '../../schemas/common'; +import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema'; // Status transition rules matching PHP const VALID_TRANSITIONS: Record = { @@ -236,7 +238,9 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise< // POST /api/admin/invoices fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => { - const body = request.body as Record; + const parsed = parseBody(CreateInvoiceSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const invoice = await prisma.invoices.create({ data: { @@ -285,7 +289,9 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise< fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateInvoiceSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.invoices.findUnique({ where: { id } }); if (!existing) return error(reply, 'Faktura nenalezena', 404); diff --git a/src/routes/admin/leave-requests.ts b/src/routes/admin/leave-requests.ts index 816a107..c0c55f0 100644 --- a/src/routes/admin/leave-requests.ts +++ b/src/routes/admin/leave-requests.ts @@ -5,6 +5,8 @@ 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'; const VALID_LEAVE_TYPES = ['vacation', 'sick', 'unpaid'] as const; const VALID_REVIEW_STATUSES = ['approved', 'rejected'] as const; @@ -36,20 +38,18 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro }); fastify.post('/', { preHandler: requireAuth }, async (request, reply) => { - const body = request.body as Record; + 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 = String(body.leave_type || ''); + 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 (!body.date_from || !body.date_to) { - return error(reply, 'Datum od a do je povinné', 400); - } - - const dateFrom = new Date(String(body.date_from)); - const dateTo = new Date(String(body.date_to)); + 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); @@ -92,10 +92,12 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro 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 body = request.body as Record; + 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 = String(body.status || ''); + const status = body.status; if (!VALID_REVIEW_STATUSES.includes(status as typeof VALID_REVIEW_STATUSES[number])) { return error(reply, 'Neplatný stav', 400); } diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index 43360e1..2202777 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -4,6 +4,8 @@ import { 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 { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema'; import multipart from '@fastify/multipart'; @@ -275,12 +277,14 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise; + const rawBody = request.body as Record; // From-quotation flow via JSON (no attachment) - if (body.quotationId) { - const quotationId = Number(body.quotationId); - const customerOrderNumber = body.customerOrderNumber ? String(body.customerOrderNumber) : ''; + if (rawBody.quotationId) { + const fromQuotParsed = parseBody(CreateOrderFromQuotationSchema, rawBody); + if ('error' in fromQuotParsed) return error(reply, fromQuotParsed.error, 400); + const quotationId = fromQuotParsed.data.quotationId; + const customerOrderNumber = fromQuotParsed.data.customerOrderNumber; if (!quotationId || isNaN(quotationId)) { return error(reply, 'Chybí ID nabídky', 400); @@ -369,21 +373,25 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateOrderSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.orders.findUnique({ where: { id } }); if (!existing) return error(reply, 'Objednávka nenalezena', 404); diff --git a/src/routes/admin/profile.ts b/src/routes/admin/profile.ts index 64076a3..56fd361 100644 --- a/src/routes/admin/profile.ts +++ b/src/routes/admin/profile.ts @@ -5,6 +5,8 @@ import { success, error } from '../../utils/response'; import bcrypt from 'bcryptjs'; import { config } from '../../config/env'; import { logAudit } from '../../services/audit'; +import { parseBody } from '../../schemas/common'; +import { UpdateProfileSchema } from '../../schemas/profile.schema'; export default async function profileRoutes(fastify: FastifyInstance): Promise { fastify.get('/', { preHandler: requireAuth }, async (request, reply) => { @@ -21,7 +23,9 @@ export default async function profileRoutes(fastify: FastifyInstance): Promise { - const body = request.body as Record; + const parsed = parseBody(UpdateProfileSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const userId = request.authData!.userId; const data: Record = {}; diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts index b19ed84..f63b51a 100644 --- a/src/routes/admin/projects.ts +++ b/src/routes/admin/projects.ts @@ -4,6 +4,8 @@ import { 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 { CreateProjectSchema, UpdateProjectSchema, CreateProjectNoteSchema } from '../../schemas/projects.schema'; const PROJECT_ALLOWED_SORT_FIELDS = ['id', 'project_number', 'name', 'status', 'created_at']; @@ -52,7 +54,9 @@ export default async function projectsRoutes(fastify: FastifyInstance): Promise< }); fastify.post('/', { preHandler: requirePermission('projects.create') }, async (request, reply) => { - const body = request.body as Record; + const parsed = parseBody(CreateProjectSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const project = await prisma.projects.create({ data: { @@ -76,7 +80,9 @@ export default async function projectsRoutes(fastify: FastifyInstance): Promise< fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateProjectSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return error(reply, 'Projekt nenalezen', 404); @@ -100,7 +106,9 @@ export default async function projectsRoutes(fastify: FastifyInstance): Promise< fastify.post<{ Params: { id: string } }>('/:id/notes', { preHandler: requirePermission('projects.edit') }, async (request, reply) => { const projectId = parseId(request.params.id, reply); if (projectId === null) return; - const body = request.body as Record; + const parsed = parseBody(CreateProjectNoteSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const authData = request.authData!; const note = await prisma.project_notes.create({ diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index c00d124..ecfbc38 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -4,6 +4,8 @@ import { 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 { CreateQuotationSchema, UpdateQuotationSchema } from '../../schemas/offers.schema'; interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } @@ -199,7 +201,9 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis }); fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => { - const body = request.body as Record; + const parsed = parseBody(CreateQuotationSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const quotation = await prisma.quotations.create({ data: { @@ -252,7 +256,9 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateQuotationSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.quotations.findUnique({ where: { id } }); if (!existing) return error(reply, 'Nabídka nenalezena', 404); diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index fc85f87..5c7c08c 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -6,6 +6,8 @@ import { 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 { CreateReceivedInvoiceSchema, UpdateReceivedInvoiceSchema } from '../../schemas/received-invoices.schema'; const VALID_STATUSES = ['unpaid', 'paid'] as const; const ALLOWED_SORT_FIELDS = ['id', 'supplier_name', 'amount', 'issue_date', 'due_date', 'status', 'created_at']; @@ -173,16 +175,16 @@ export default async function receivedInvoicesRoutes(fastify: FastifyInstance): } // JSON body: single invoice creation (no file) - const body = request.body as Record; - const status = body.status ? String(body.status) : 'unpaid'; + const parsed = parseBody(CreateReceivedInvoiceSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + const status = body.status; if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) { return error(reply, 'Neplatný stav', 400); } - const amount = Number(body.amount ?? 0); - const vatRate = Number(body.vat_rate ?? 21); - - if (!body.supplier_name) return error(reply, 'Název dodavatele je povinný', 400); + const amount = body.amount; + const vatRate = body.vat_rate; const invoice = await prisma.received_invoices.create({ data: { month: Number(body.month), @@ -209,7 +211,9 @@ export default async function receivedInvoicesRoutes(fastify: FastifyInstance): fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateReceivedInvoiceSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.received_invoices.findUnique({ where: { id } }); if (!existing) return error(reply, 'Přijatá faktura nenalezena', 404); diff --git a/src/routes/admin/roles.ts b/src/routes/admin/roles.ts index e98b644..5d06779 100644 --- a/src/routes/admin/roles.ts +++ b/src/routes/admin/roles.ts @@ -3,6 +3,8 @@ import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; +import { parseBody } from '../../schemas/common'; +import { CreateRoleSchema, UpdateRoleSchema } from '../../schemas/roles.schema'; export default async function rolesRoutes(fastify: FastifyInstance): Promise { // GET /api/admin/roles @@ -32,7 +34,9 @@ export default async function rolesRoutes(fastify: FastifyInstance): Promise { - const body = request.body as Record; + const parsed = parseBody(CreateRoleSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const role = await prisma.roles.create({ data: { @@ -67,7 +71,9 @@ export default async function rolesRoutes(fastify: FastifyInstance): Promise('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateRoleSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.roles.findUnique({ where: { id } }); if (!existing) return error(reply, 'Role nenalezena', 404); diff --git a/src/routes/admin/scope-templates.ts b/src/routes/admin/scope-templates.ts index bd2eb2b..720235a 100644 --- a/src/routes/admin/scope-templates.ts +++ b/src/routes/admin/scope-templates.ts @@ -2,6 +2,8 @@ import { FastifyInstance } from 'fastify'; import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { success, error, parseId } from '../../utils/response'; +import { parseBody } from '../../schemas/common'; +import { CreateScopeTemplateSchema, CreateItemTemplateSchema, UpdateScopeTemplateSchema } from '../../schemas/scope-templates.schema'; interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number } @@ -32,9 +34,11 @@ export default async function scopeTemplatesRoutes(fastify: FastifyInstance): Pr // Item template CRUD via ?action=item fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => { const query = request.query as Record; - const body = request.body as Record; if (String(query.action) === 'item') { + const itemParsed = parseBody(CreateItemTemplateSchema, request.body); + if ('error' in itemParsed) return error(reply, itemParsed.error, 400); + const body = itemParsed.data; const itemData = { name: body.name ? String(body.name) : null, description: body.description ? String(body.description) : null, @@ -58,6 +62,10 @@ export default async function scopeTemplatesRoutes(fastify: FastifyInstance): Pr } // Scope template create (original logic below) + const scopeParsed = parseBody(CreateScopeTemplateSchema, request.body); + if ('error' in scopeParsed) return error(reply, scopeParsed.error, 400); + const body = scopeParsed.data; + const template = await prisma.scope_templates.create({ data: { name: body.name ? String(body.name) : null, @@ -108,7 +116,9 @@ export default async function scopeTemplatesRoutes(fastify: FastifyInstance): Pr fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateScopeTemplateSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.scope_templates.findUnique({ where: { id } }); if (!existing) return error(reply, 'Šablona nenalezena', 404); diff --git a/src/routes/admin/trips.ts b/src/routes/admin/trips.ts index f72f79b..3cc3075 100644 --- a/src/routes/admin/trips.ts +++ b/src/routes/admin/trips.ts @@ -4,6 +4,8 @@ import { requireAuth, requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; +import { parseBody } from '../../schemas/common'; +import { CreateTripSchema, UpdateTripSchema } from '../../schemas/trips.schema'; export default async function tripsRoutes(fastify: FastifyInstance): Promise { fastify.get('/', { preHandler: requireAuth }, async (request, reply) => { @@ -118,7 +120,9 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise { - const body = request.body as Record; + const parsed = parseBody(CreateTripSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const authData = request.authData!; const trip = await prisma.trips.create({ @@ -148,7 +152,9 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise('/:id', { preHandler: requireAuth }, async (request, reply) => { const id = parseInt(request.params.id, 10); if (isNaN(id)) return error(reply, 'Neplatné ID', 400); - const body = request.body as Record; + const parsed = parseBody(UpdateTripSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const authData = request.authData!; const existing = await prisma.trips.findUnique({ where: { id } }); diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts index f391a91..0c831ed 100644 --- a/src/routes/admin/users.ts +++ b/src/routes/admin/users.ts @@ -6,6 +6,8 @@ import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import bcrypt from 'bcryptjs'; import { config } from '../../config/env'; +import { parseBody } from '../../schemas/common'; +import { CreateUserSchema, UpdateUserSchema } from '../../schemas/users.schema'; const ALLOWED_SORT_FIELDS = ['id', 'username', 'email', 'first_name', 'last_name', 'created_at']; @@ -69,25 +71,17 @@ export default async function usersRoutes(fastify: FastifyInstance): Promise { - const body = request.body as Record; + const parsed = parseBody(CreateUserSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; - const username = body.username ? String(body.username).trim() : ''; - const email = body.email ? String(body.email).trim() : ''; - const password = body.password ? String(body.password) : ''; - const firstName = body.first_name ? String(body.first_name).trim() : ''; - const lastName = body.last_name ? String(body.last_name).trim() : ''; + const username = body.username.trim(); + const email = body.email.trim(); + const password = body.password; + const firstName = body.first_name.trim(); + const lastName = body.last_name.trim(); const roleId = body.role_id; - // Required fields - if (!username || !email || !password || !firstName || !lastName || !roleId) { - return error(reply, 'Všechna pole jsou povinná', 400); - } - - // Password length - if (password.length < 8) { - return error(reply, 'Heslo musí mít alespoň 8 znaků', 400); - } - // Email format if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return error(reply, 'Neplatný formát e-mailu', 400); @@ -135,7 +129,9 @@ export default async function usersRoutes(fastify: FastifyInstance): Promise('/:id', { preHandler: requirePermission('users.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateUserSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.users.findUnique({ where: { id } }); if (!existing) return error(reply, 'Uživatel nenalezen', 404); diff --git a/src/routes/admin/vehicles.ts b/src/routes/admin/vehicles.ts index ef3bab1..73c8b3b 100644 --- a/src/routes/admin/vehicles.ts +++ b/src/routes/admin/vehicles.ts @@ -3,6 +3,8 @@ import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; +import { parseBody } from '../../schemas/common'; +import { CreateVehicleSchema, UpdateVehicleSchema } from '../../schemas/vehicles.schema'; export default async function vehiclesRoutes(fastify: FastifyInstance): Promise { fastify.get('/', { preHandler: requirePermission('trips.vehicles') }, async (_request, reply) => { @@ -29,16 +31,17 @@ export default async function vehiclesRoutes(fastify: FastifyInstance): Promise< }); fastify.post('/', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => { - const body = request.body as Record; - if (!body.spz || !body.name) return error(reply, 'SPZ a název jsou povinné', 400); + const parsed = parseBody(CreateVehicleSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const vehicle = await prisma.vehicles.create({ data: { - spz: String(body.spz), - name: String(body.name), - brand: body.brand ? String(body.brand) : null, - model: body.model ? String(body.model) : null, - initial_km: body.initial_km ? Number(body.initial_km) : 0, - actual_km: body.actual_km ? Number(body.actual_km) : 0, + spz: body.spz, + name: body.name, + brand: body.brand ?? null, + model: body.model ?? null, + initial_km: body.initial_km, + actual_km: body.actual_km, is_active: body.is_active !== false, }, }); @@ -50,7 +53,9 @@ export default async function vehiclesRoutes(fastify: FastifyInstance): Promise< fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const body = request.body as Record; + const parsed = parseBody(UpdateVehicleSchema, request.body); + if ('error' in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; const existing = await prisma.vehicles.findUnique({ where: { id } }); if (!existing) return error(reply, 'Vozidlo nenalezeno', 404); diff --git a/src/schemas/attendance.schema.ts b/src/schemas/attendance.schema.ts new file mode 100644 index 0000000..83989dc --- /dev/null +++ b/src/schemas/attendance.schema.ts @@ -0,0 +1,97 @@ +import { z } from 'zod'; + +export const AttendanceNotesSchema = z.object({ + notes: z.string().nullish(), +}); + +export const AttendanceUpdateAddressSchema = z.object({ + address: z.string().max(500).nullish(), + punch_action: z.string().optional().default('arrival'), +}); + +export const AttendanceSwitchProjectSchema = z.object({ + project_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), +}); + +export const AttendanceBalancesSchema = z.object({ + user_id: z.union([z.number(), z.string()]).transform(v => Number(v)), + year: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + action_type: z.string(), + vacation_total: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + vacation_used: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + sick_used: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), +}); + +export const AttendanceBulkSchema = z.object({ + month: z.string().regex(/^\d{4}-\d{2}$/, 'Měsíc je povinný (formát YYYY-MM)'), + user_ids: z.array(z.number()).min(1, 'Vyberte alespoň jednoho zaměstnance'), + arrival_time: z.string().optional().default('08:00'), + departure_time: z.string().optional().default('16:30'), + break_start_time: z.string().optional().default('12:00'), + break_end_time: z.string().optional().default('12:30'), +}); + +export const AttendanceLeaveSchema = z.object({ + user_id: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + date_from: z.string().min(1, 'Datum je povinné'), + date_to: z.string().optional(), + leave_type: z.string().optional().default('vacation'), + leave_hours: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + notes: z.string().nullish(), +}); + +const ProjectLogSchema = z.object({ + project_id: z.union([z.number(), z.string()]).transform(v => Number(v)), + hours: z.number().optional().default(0), + minutes: z.number().optional().default(0), +}); + +export const AttendancePunchSchema = z.object({ + punch_action: z.string(), + latitude: z.union([z.number(), z.string(), z.null()]).optional(), + longitude: z.union([z.number(), z.string(), z.null()]).optional(), + accuracy: z.union([z.number(), z.string(), z.null()]).optional(), + address: z.string().max(500).nullish(), +}); + +export const CreateAttendanceSchema = z.object({ + user_id: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + shift_date: z.string(), + arrival_time: z.string().nullish(), + arrival_lat: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + arrival_lng: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + arrival_accuracy: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + arrival_address: z.string().nullish(), + departure_time: z.string().nullish(), + departure_lat: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + departure_lng: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + departure_accuracy: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + departure_address: z.string().nullish(), + notes: z.string().nullish(), + project_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + leave_type: z.string().optional().default('work'), + leave_hours: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + project_logs: z.array(ProjectLogSchema).optional(), +}); + +export const UpdateAttendanceSchema = z.object({ + arrival_time: z.union([z.string(), z.null()]).optional(), + departure_time: z.union([z.string(), z.null()]).optional(), + break_start: z.union([z.string(), z.null()]).optional(), + break_end: z.union([z.string(), z.null()]).optional(), + notes: z.string().nullish(), + project_id: z.union([z.number(), z.string(), z.null()]).optional(), + leave_type: z.string().optional(), + leave_hours: z.union([z.number(), z.string(), z.null()]).optional(), + project_logs: z.array(ProjectLogSchema).optional(), +}); + +export type AttendanceNotesInput = z.infer; +export type AttendanceUpdateAddressInput = z.infer; +export type AttendanceSwitchProjectInput = z.infer; +export type AttendanceBalancesInput = z.infer; +export type AttendanceBulkInput = z.infer; +export type AttendanceLeaveInput = z.infer; +export type AttendancePunchInput = z.infer; +export type CreateAttendanceInput = z.infer; +export type UpdateAttendanceInput = z.infer; diff --git a/src/schemas/bank-accounts.schema.ts b/src/schemas/bank-accounts.schema.ts new file mode 100644 index 0000000..14a3739 --- /dev/null +++ b/src/schemas/bank-accounts.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const CreateBankAccountSchema = z.object({ + account_name: z.string().nullish(), + bank_name: z.string().nullish(), + account_number: z.string().nullish(), + iban: z.string().nullish(), + bic: z.string().nullish(), + currency: z.string().optional().default('CZK'), + is_default: z.any().optional().default(false), + position: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(0), +}); + +export const UpdateBankAccountSchema = z.object({ + account_name: z.string().nullish(), + bank_name: z.string().nullish(), + account_number: z.string().nullish(), + iban: z.string().nullish(), + bic: z.string().nullish(), + currency: z.string().optional(), + is_default: z.any().optional(), + position: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), +}); + +export type CreateBankAccountInput = z.infer; +export type UpdateBankAccountInput = z.infer; diff --git a/src/schemas/company-settings.schema.ts b/src/schemas/company-settings.schema.ts new file mode 100644 index 0000000..fc5f05b --- /dev/null +++ b/src/schemas/company-settings.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const UpdateCompanySettingsSchema = z.object({ + company_name: z.string().nullish(), + street: z.string().nullish(), + city: z.string().nullish(), + postal_code: z.string().nullish(), + country: z.string().nullish(), + company_id: z.string().nullish(), + vat_id: z.string().nullish(), + quotation_prefix: z.string().nullish(), + default_currency: z.string().nullish(), + order_type_code: z.string().nullish(), + invoice_type_code: z.string().nullish(), + default_vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + require_2fa: z.any().optional(), + custom_fields: z.array(z.any()).optional(), + supplier_field_order: z.array(z.any()).optional(), +}); + +export type UpdateCompanySettingsInput = z.infer; diff --git a/src/schemas/customers.schema.ts b/src/schemas/customers.schema.ts new file mode 100644 index 0000000..a2e0d65 --- /dev/null +++ b/src/schemas/customers.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const CreateCustomerSchema = z.object({ + name: z.string().min(1, 'Název zákazníka je povinný'), + street: z.string().nullish(), + city: z.string().nullish(), + postal_code: z.string().nullish(), + country: z.string().nullish(), + company_id: z.string().nullish(), + vat_id: z.string().nullish(), + custom_fields: z.array(z.any()).optional(), + customer_field_order: z.array(z.string()).optional(), +}); + +export const UpdateCustomerSchema = z.object({ + name: z.string().optional(), + street: z.string().nullish(), + city: z.string().nullish(), + postal_code: z.string().nullish(), + country: z.string().nullish(), + company_id: z.string().nullish(), + vat_id: z.string().nullish(), + custom_fields: z.array(z.any()).optional(), + customer_field_order: z.array(z.string()).optional(), +}); + +export type CreateCustomerInput = z.infer; +export type UpdateCustomerInput = z.infer; diff --git a/src/schemas/invoices.schema.ts b/src/schemas/invoices.schema.ts new file mode 100644 index 0000000..f3be3e2 --- /dev/null +++ b/src/schemas/invoices.schema.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +const InvoiceItemSchema = z.object({ + description: z.string().nullish(), + quantity: z.number().optional().default(1), + unit: z.string().nullish(), + unit_price: z.number().optional().default(0), + vat_rate: z.number().optional().default(21.0), + position: z.number().optional(), +}); + +export const CreateInvoiceSchema = z.object({ + invoice_number: z.string().nullish(), + order_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + customer_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + status: z.string().optional().default('issued'), + currency: z.string().optional().default('CZK'), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(21.0), + apply_vat: z.any().optional().default(true), + payment_method: z.string().nullish(), + constant_symbol: z.string().nullish(), + bank_name: z.string().nullish(), + bank_swift: z.string().nullish(), + bank_iban: z.string().nullish(), + bank_account: z.string().nullish(), + issue_date: z.string().nullish(), + due_date: z.string().nullish(), + tax_date: z.string().nullish(), + issued_by: z.string().nullish(), + notes: z.string().nullish(), + internal_notes: z.string().nullish(), + items: z.array(InvoiceItemSchema).optional(), +}); + +export const UpdateInvoiceSchema = z.object({ + status: z.string().optional(), + currency: z.string().optional(), + payment_method: z.string().nullish(), + constant_symbol: z.string().nullish(), + bank_name: z.string().nullish(), + bank_swift: z.string().nullish(), + bank_iban: z.string().nullish(), + bank_account: z.string().nullish(), + issued_by: z.string().nullish(), + customer_id: z.union([z.number(), z.string(), z.null()]).optional(), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + apply_vat: z.any().optional(), + issue_date: z.union([z.string(), z.null()]).optional(), + due_date: z.union([z.string(), z.null()]).optional(), + tax_date: z.union([z.string(), z.null()]).optional(), + notes: z.string().nullish(), + internal_notes: z.string().nullish(), + paid_date: z.union([z.string(), z.null()]).optional(), + items: z.array(InvoiceItemSchema).optional(), +}); + +export type CreateInvoiceInput = z.infer; +export type UpdateInvoiceInput = z.infer; diff --git a/src/schemas/leave-requests.schema.ts b/src/schemas/leave-requests.schema.ts new file mode 100644 index 0000000..eb1be48 --- /dev/null +++ b/src/schemas/leave-requests.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const CreateLeaveRequestSchema = z.object({ + leave_type: z.string().min(1, 'Typ nepřítomnosti je povinný'), + date_from: z.string().min(1, 'Datum od je povinné'), + date_to: z.string().min(1, 'Datum do je povinné'), + notes: z.string().nullish(), +}); + +export const ReviewLeaveRequestSchema = z.object({ + status: z.string().min(1, 'Stav je povinný'), + reviewer_note: z.string().nullish(), +}); + +export type CreateLeaveRequestInput = z.infer; +export type ReviewLeaveRequestInput = z.infer; diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts new file mode 100644 index 0000000..228f609 --- /dev/null +++ b/src/schemas/offers.schema.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +const QuotationItemSchema = z.object({ + description: z.string().nullish(), + item_description: z.string().nullish(), + quantity: z.number().optional().default(1), + unit: z.string().nullish(), + unit_price: z.number().optional().default(0), + is_included_in_total: z.any().optional().default(true), + position: z.number().optional(), +}); + +const ScopeSectionSchema = z.object({ + title: z.string().nullish(), + title_cz: z.string().nullish(), + content: z.string().nullish(), + position: z.number().optional(), +}); + +export const CreateQuotationSchema = z.object({ + quotation_number: z.string().nullish(), + project_code: z.string().nullish(), + customer_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + valid_until: z.string().nullish(), + currency: z.string().optional().default('CZK'), + language: z.string().optional().default('cs'), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(21.0), + apply_vat: z.any().optional().default(true), + exchange_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(1.0), + status: z.string().optional().default('active'), + scope_title: z.string().nullish(), + scope_description: z.string().nullish(), + items: z.array(QuotationItemSchema).optional(), + sections: z.array(ScopeSectionSchema).optional(), +}); + +export const UpdateQuotationSchema = z.object({ + quotation_number: z.string().optional(), + project_code: z.string().nullish(), + customer_id: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + valid_until: z.union([z.string(), z.null()]).optional(), + currency: z.string().optional(), + language: z.string().optional(), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + apply_vat: z.any().optional(), + exchange_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + status: z.string().optional(), + scope_title: z.string().nullish(), + scope_description: z.string().nullish(), + items: z.array(QuotationItemSchema).optional(), + sections: z.array(ScopeSectionSchema).optional(), +}); + +export type CreateQuotationInput = z.infer; +export type UpdateQuotationInput = z.infer; diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts new file mode 100644 index 0000000..c8718d0 --- /dev/null +++ b/src/schemas/orders.schema.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +const OrderItemSchema = z.object({ + description: z.string().nullish(), + item_description: z.string().nullish(), + quantity: z.number().optional().default(1), + unit: z.string().nullish(), + unit_price: z.number().optional().default(0), + is_included_in_total: z.any().optional().default(true), + position: z.number().optional(), +}); + +const OrderSectionSchema = z.object({ + title: z.string().nullish(), + title_cz: z.string().nullish(), + content: z.string().nullish(), + position: z.number().optional(), +}); + +export const CreateOrderFromQuotationSchema = z.object({ + quotationId: z.union([z.number(), z.string()]).transform(v => Number(v)), + customerOrderNumber: z.string().optional().default(''), +}); + +export const CreateOrderSchema = z.object({ + order_number: z.string().nullish(), + customer_order_number: z.string().nullish(), + quotation_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + customer_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + status: z.string().optional().default('prijata'), + currency: z.string().optional().default('CZK'), + language: z.string().optional().default('cs'), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(21.0), + apply_vat: z.any().optional().default(true), + exchange_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(1.0), + scope_title: z.string().nullish(), + scope_description: z.string().nullish(), + notes: z.string().nullish(), + items: z.array(OrderItemSchema).optional(), + sections: z.array(OrderSectionSchema).optional(), +}); + +export const UpdateOrderSchema = z.object({ + order_number: z.string().nullish(), + customer_order_number: z.string().nullish(), + status: z.string().optional(), + currency: z.string().optional(), + language: z.string().optional(), + scope_title: z.string().nullish(), + scope_description: z.string().nullish(), + notes: z.string().nullish(), + customer_id: z.union([z.number(), z.string(), z.null()]).optional(), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + apply_vat: z.any().optional(), + items: z.array(OrderItemSchema).optional(), + sections: z.array(OrderSectionSchema).optional(), +}); + +export type CreateOrderFromQuotationInput = z.infer; +export type CreateOrderInput = z.infer; +export type UpdateOrderInput = z.infer; diff --git a/src/schemas/profile.schema.ts b/src/schemas/profile.schema.ts new file mode 100644 index 0000000..9020fe5 --- /dev/null +++ b/src/schemas/profile.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const UpdateProfileSchema = z.object({ + email: z.string().email('Neplatný formát e-mailu').optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + current_password: z.string().optional(), + new_password: z.string().optional(), +}); + +export type UpdateProfileInput = z.infer; diff --git a/src/schemas/projects.schema.ts b/src/schemas/projects.schema.ts new file mode 100644 index 0000000..e84d97d --- /dev/null +++ b/src/schemas/projects.schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export const CreateProjectSchema = z.object({ + project_number: z.string().nullish(), + name: z.string().nullish(), + customer_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + responsible_user_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + quotation_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + order_id: z.union([z.number(), z.string()]).transform(v => Number(v)).nullish(), + status: z.string().optional().default('aktivni'), + start_date: z.string().nullish(), + end_date: z.string().nullish(), + notes: z.string().nullish(), +}); + +export const UpdateProjectSchema = z.object({ + project_number: z.string().nullish(), + name: z.string().nullish(), + status: z.string().optional(), + notes: z.string().nullish(), + customer_id: z.union([z.number(), z.string(), z.null()]).optional(), + responsible_user_id: z.union([z.number(), z.string(), z.null()]).optional(), + quotation_id: z.union([z.number(), z.string(), z.null()]).optional(), + order_id: z.union([z.number(), z.string(), z.null()]).optional(), + start_date: z.union([z.string(), z.null()]).optional(), + end_date: z.union([z.string(), z.null()]).optional(), +}); + +export const CreateProjectNoteSchema = z.object({ + content: z.string().nullish(), +}); + +export type CreateProjectInput = z.infer; +export type UpdateProjectInput = z.infer; +export type CreateProjectNoteInput = z.infer; diff --git a/src/schemas/received-invoices.schema.ts b/src/schemas/received-invoices.schema.ts new file mode 100644 index 0000000..ca72722 --- /dev/null +++ b/src/schemas/received-invoices.schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const CreateReceivedInvoiceSchema = z.object({ + supplier_name: z.string().min(1, 'Název dodavatele je povinný'), + month: z.union([z.number(), z.string()]).transform(v => Number(v)), + year: z.union([z.number(), z.string()]).transform(v => Number(v)), + invoice_number: z.string().nullish(), + description: z.string().nullish(), + amount: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(0), + currency: z.string().optional().default('CZK'), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(21), + vat_amount: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(0), + issue_date: z.string().nullish(), + due_date: z.string().nullish(), + status: z.string().optional().default('unpaid'), + notes: z.string().nullish(), +}); + +export const UpdateReceivedInvoiceSchema = z.object({ + supplier_name: z.string().optional(), + invoice_number: z.string().nullish(), + description: z.string().nullish(), + amount: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + currency: z.string().optional(), + vat_rate: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + vat_amount: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + issue_date: z.union([z.string(), z.null()]).optional(), + due_date: z.union([z.string(), z.null()]).optional(), + paid_date: z.union([z.string(), z.null()]).optional(), + status: z.string().optional(), + notes: z.string().nullish(), + month: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + year: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), +}); + +export type CreateReceivedInvoiceInput = z.infer; +export type UpdateReceivedInvoiceInput = z.infer; diff --git a/src/schemas/roles.schema.ts b/src/schemas/roles.schema.ts new file mode 100644 index 0000000..eac4a23 --- /dev/null +++ b/src/schemas/roles.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const CreateRoleSchema = z.object({ + name: z.string().min(1, 'Název role je povinný'), + display_name: z.string().min(1, 'Zobrazovaný název je povinný'), + description: z.string().nullish(), + permission_ids: z.array(z.number()).optional(), +}); + +export const UpdateRoleSchema = z.object({ + display_name: z.string().optional(), + description: z.string().optional(), + permission_ids: z.array(z.number()).optional(), +}); + +export type CreateRoleInput = z.infer; +export type UpdateRoleInput = z.infer; diff --git a/src/schemas/scope-templates.schema.ts b/src/schemas/scope-templates.schema.ts new file mode 100644 index 0000000..8712d37 --- /dev/null +++ b/src/schemas/scope-templates.schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +const ScopeSectionSchema = z.object({ + title: z.string().nullish(), + title_cz: z.string().nullish(), + content: z.string().nullish(), + position: z.number().optional(), +}); + +export const CreateScopeTemplateSchema = z.object({ + name: z.string().nullish(), + title: z.string().nullish(), + description: z.string().nullish(), + sections: z.array(ScopeSectionSchema).optional(), +}); + +export const CreateItemTemplateSchema = z.object({ + id: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + name: z.string().nullish(), + description: z.string().nullish(), + default_price: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(0), + category: z.string().nullish(), +}); + +export const UpdateScopeTemplateSchema = z.object({ + name: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + sections: z.array(ScopeSectionSchema).optional(), +}); + +export type CreateScopeTemplateInput = z.infer; +export type CreateItemTemplateInput = z.infer; +export type UpdateScopeTemplateInput = z.infer; diff --git a/src/schemas/trips.schema.ts b/src/schemas/trips.schema.ts new file mode 100644 index 0000000..f477570 --- /dev/null +++ b/src/schemas/trips.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const CreateTripSchema = z.object({ + vehicle_id: z.union([z.number(), z.string()]).transform(v => Number(v)), + user_id: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + trip_date: z.string(), + start_km: z.union([z.number(), z.string()]).transform(v => Number(v)), + end_km: z.union([z.number(), z.string()]).transform(v => Number(v)), + route_from: z.string(), + route_to: z.string(), + is_business: z.any().optional().default(false), + notes: z.string().nullish(), +}); + +export const UpdateTripSchema = z.object({ + trip_date: z.string().optional(), + start_km: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + end_km: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + route_from: z.string().optional(), + route_to: z.string().optional(), + is_business: z.any().optional(), + notes: z.string().nullish(), +}); + +export type CreateTripInput = z.infer; +export type UpdateTripInput = z.infer; diff --git a/src/schemas/users.schema.ts b/src/schemas/users.schema.ts new file mode 100644 index 0000000..d91efd9 --- /dev/null +++ b/src/schemas/users.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export const CreateUserSchema = z.object({ + username: z.string().min(1, 'Uživatelské jméno je povinné'), + email: z.string().email('Neplatný formát e-mailu'), + password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků'), + first_name: z.string().min(1, 'Jméno je povinné'), + last_name: z.string().min(1, 'Příjmení je povinné'), + role_id: z.union([z.number(), z.string()]).transform(v => Number(v)), + is_active: z.any().optional().default(true), +}); + +export const UpdateUserSchema = z.object({ + username: z.string().optional(), + email: z.string().email('Neplatný formát e-mailu').optional(), + password: z.string().min(8, 'Heslo musí mít alespoň 8 znaků').optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + role_id: z.union([z.number(), z.string(), z.null()]).optional(), + is_active: z.any().optional(), +}); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; diff --git a/src/schemas/vehicles.schema.ts b/src/schemas/vehicles.schema.ts new file mode 100644 index 0000000..ad01367 --- /dev/null +++ b/src/schemas/vehicles.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export const CreateVehicleSchema = z.object({ + spz: z.string().min(1, 'SPZ je povinná'), + name: z.string().min(1, 'Název je povinný'), + brand: z.string().nullish(), + model: z.string().nullish(), + initial_km: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(0), + actual_km: z.union([z.number(), z.string()]).transform(v => Number(v)).optional().default(0), + is_active: z.any().optional().default(true), +}); + +export const UpdateVehicleSchema = z.object({ + spz: z.string().optional(), + name: z.string().optional(), + brand: z.string().nullish(), + model: z.string().nullish(), + initial_km: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + actual_km: z.union([z.number(), z.string()]).transform(v => Number(v)).optional(), + is_active: z.any().optional(), +}); + +export type CreateVehicleInput = z.infer; +export type UpdateVehicleInput = z.infer;