style: run prettier on entire codebase
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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 { FastifyInstance } from "fastify";
|
||||
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,
|
||||
@@ -14,124 +14,158 @@ import {
|
||||
AttendancePunchSchema,
|
||||
CreateAttendanceSchema,
|
||||
UpdateAttendanceSchema,
|
||||
} from '../../schemas/attendance.schema';
|
||||
import * as attendanceService from '../../services/attendance.service';
|
||||
|
||||
export default async function attendanceRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
} from "../../schemas/attendance.schema";
|
||||
import * as attendanceService from "../../services/attendance.service";
|
||||
|
||||
export default async function attendanceRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// GET /api/admin/attendance/status — clock-in/out page data
|
||||
fastify.get('/status', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const data = await attendanceService.getStatus(authData.userId);
|
||||
return reply.send({ success: true, data });
|
||||
});
|
||||
fastify.get(
|
||||
"/status",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const data = await attendanceService.getStatus(authData.userId);
|
||||
return reply.send({ success: true, data });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/attendance/notes — save shift notes
|
||||
fastify.post('/notes', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceNotesSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/notes",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceNotesSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await attendanceService.saveNotes(authData.userId, body.notes ? String(body.notes) : null);
|
||||
if ('error' in result) return error(reply, result.error!, 400);
|
||||
return success(reply, null, 200, 'Poznámka uložena');
|
||||
});
|
||||
const result = await attendanceService.saveNotes(
|
||||
authData.userId,
|
||||
body.notes ? String(body.notes) : null,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, 400);
|
||||
return success(reply, null, 200, "Poznámka uložena");
|
||||
},
|
||||
);
|
||||
|
||||
// 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 parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/update-address",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await attendanceService.updateAddress(authData.userId, body.address ?? null, body.punch_action);
|
||||
if ('error' in result) return error(reply, result.error!, 404);
|
||||
return success(reply, null, 200, 'Adresa aktualizována');
|
||||
});
|
||||
const result = await attendanceService.updateAddress(
|
||||
authData.userId,
|
||||
body.address ?? null,
|
||||
body.punch_action,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, 404);
|
||||
return success(reply, null, 200, "Adresa aktualizována");
|
||||
},
|
||||
);
|
||||
|
||||
// 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 parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/switch-project",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const newProjectId = body.project_id ? Number(body.project_id) : null;
|
||||
const result = await attendanceService.switchProject(authData.userId, newProjectId);
|
||||
if ('error' in result) return error(reply, result.error!, 400);
|
||||
return success(reply, null, 200, 'Projekt přepnut');
|
||||
});
|
||||
const newProjectId = body.project_id ? Number(body.project_id) : null;
|
||||
const result = await attendanceService.switchProject(
|
||||
authData.userId,
|
||||
newProjectId,
|
||||
);
|
||||
if ("error" in result) return error(reply, result.error!, 400);
|
||||
return success(reply, null, 200, "Projekt přepnut");
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/attendance
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const authData = request.authData!;
|
||||
const action = query.action ? String(query.action) : null;
|
||||
|
||||
// --- action=balances: leave balance overview for all users ---
|
||||
if (action === 'balances') {
|
||||
if (action === "balances") {
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getBalances(yr);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=workfund: monthly work fund overview ---
|
||||
if (action === 'workfund') {
|
||||
if (action === "workfund") {
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getWorkfund(yr);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=project_report: monthly project hours ---
|
||||
if (action === 'project_report') {
|
||||
if (action === "project_report") {
|
||||
const yr = Number(query.year) || new Date().getFullYear();
|
||||
const data = await attendanceService.getProjectReport(yr);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=print: attendance print data for admin ---
|
||||
if (action === 'print') {
|
||||
if (!authData.permissions.includes('attendance.admin')) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
if (action === "print") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const monthStr = query.month ? String(query.month) : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const monthStr = query.month
|
||||
? String(query.month)
|
||||
: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`;
|
||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
||||
const data = await attendanceService.getPrintData(monthStr, filterUserId);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=projects: active projects for attendance project switching ---
|
||||
if (action === 'projects') {
|
||||
if (action === "projects") {
|
||||
const data = await attendanceService.getActiveProjects();
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=project_logs: get project logs for a specific attendance record ---
|
||||
if (action === 'project_logs') {
|
||||
if (action === "project_logs") {
|
||||
const attendanceId = Number(query.attendance_id);
|
||||
if (!attendanceId) return error(reply, 'Missing attendance_id', 400);
|
||||
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
|
||||
const data = await attendanceService.getProjectLogs(attendanceId);
|
||||
return reply.send({ success: true, data });
|
||||
}
|
||||
|
||||
// --- action=location: single record with GPS data ---
|
||||
if (action === 'location') {
|
||||
if (action === "location") {
|
||||
const id = Number(query.id);
|
||||
if (!id) return error(reply, 'Missing id', 400);
|
||||
if (!id) return error(reply, "Missing id", 400);
|
||||
const record = await attendanceService.getLocationRecord(id);
|
||||
if (!record) return error(reply, 'Záznam nenalezen', 404);
|
||||
if (!record) return error(reply, "Záznam nenalezen", 404);
|
||||
return reply.send({ success: true, data: record });
|
||||
}
|
||||
|
||||
// --- Default: paginated records list ---
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
const isAdmin = authData.permissions.includes('attendance.admin');
|
||||
const isAdmin = authData.permissions.includes("attendance.admin");
|
||||
const userId = query.user_id ? Number(query.user_id) : undefined;
|
||||
|
||||
const result = await attendanceService.listAttendance({
|
||||
page, limit, skip, order, userId, isAdmin,
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
order,
|
||||
userId,
|
||||
isAdmin,
|
||||
authUserId: authData.userId,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
@@ -145,19 +179,19 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
});
|
||||
|
||||
// POST /api/admin/attendance
|
||||
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const rawBody = request.body as Record<string, unknown>;
|
||||
const authData = request.authData!;
|
||||
const postQuery = request.query as Record<string, unknown>;
|
||||
|
||||
// --- action=balances: edit or reset leave balance ---
|
||||
if (postQuery.action === 'balances') {
|
||||
if (!authData.permissions.includes('attendance.balances')) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
if (postQuery.action === "balances") {
|
||||
if (!authData.permissions.includes("attendance.balances")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const balParsed = parseBody(AttendanceBalancesSchema, rawBody);
|
||||
if ('error' in balParsed) return error(reply, balParsed.error, 400);
|
||||
if ("error" in balParsed) return error(reply, balParsed.error, 400);
|
||||
const balBody = balParsed.data;
|
||||
|
||||
const result = await attendanceService.handleBalances({
|
||||
@@ -169,12 +203,15 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
sick_used: balBody.sick_used,
|
||||
});
|
||||
|
||||
if ('error' in result) return error(reply, result.error!, 400);
|
||||
if ("error" in result) return error(reply, result.error!, 400);
|
||||
|
||||
await logAudit({
|
||||
request, authData, action: 'update', entityType: 'leave_balance',
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "leave_balance",
|
||||
entityId: balBody.user_id,
|
||||
description: result.message.includes('resetována')
|
||||
description: result.message.includes("resetována")
|
||||
? `Resetována bilance pro rok ${result.year}`
|
||||
: `Upravena bilance dovolené pro rok ${result.year}`,
|
||||
});
|
||||
@@ -182,13 +219,13 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
}
|
||||
|
||||
// --- action=bulk_attendance: bulk fill month ---
|
||||
if (postQuery.action === 'bulk_attendance') {
|
||||
if (!authData.permissions.includes('attendance.admin')) {
|
||||
return error(reply, 'Nedostatečná oprávnění', 403);
|
||||
if (postQuery.action === "bulk_attendance") {
|
||||
if (!authData.permissions.includes("attendance.admin")) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
|
||||
const bulkParsed = parseBody(AttendanceBulkSchema, rawBody);
|
||||
if ('error' in bulkParsed) return error(reply, bulkParsed.error, 400);
|
||||
if ("error" in bulkParsed) return error(reply, bulkParsed.error, 400);
|
||||
const bulkBody = bulkParsed.data;
|
||||
|
||||
const result = await attendanceService.bulkCreateAttendance({
|
||||
@@ -201,36 +238,48 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request, authData, action: 'create', entityType: 'attendance',
|
||||
entityId: 0, description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`,
|
||||
request,
|
||||
authData,
|
||||
action: "create",
|
||||
entityType: "attendance",
|
||||
entityId: 0,
|
||||
description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`,
|
||||
});
|
||||
|
||||
return success(reply, { inserted: result.inserted, skipped: result.skipped }, 200, result.message);
|
||||
return success(
|
||||
reply,
|
||||
{ inserted: result.inserted, skipped: result.skipped },
|
||||
200,
|
||||
result.message,
|
||||
);
|
||||
}
|
||||
|
||||
// --- action=leave: add leave record directly ---
|
||||
if (postQuery.action === 'leave') {
|
||||
if (postQuery.action === "leave") {
|
||||
const leaveParsed = parseBody(AttendanceLeaveSchema, rawBody);
|
||||
if ('error' in leaveParsed) return error(reply, leaveParsed.error, 400);
|
||||
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
|
||||
const leaveBody = leaveParsed.data;
|
||||
|
||||
const result = await attendanceService.createLeave({
|
||||
user_id: leaveBody.user_id,
|
||||
date_from: leaveBody.date_from,
|
||||
date_to: leaveBody.date_to,
|
||||
leave_type: leaveBody.leave_type,
|
||||
leave_hours: leaveBody.leave_hours,
|
||||
notes: leaveBody.notes ?? undefined,
|
||||
}, authData.userId);
|
||||
const result = await attendanceService.createLeave(
|
||||
{
|
||||
user_id: leaveBody.user_id,
|
||||
date_from: leaveBody.date_from,
|
||||
date_to: leaveBody.date_to,
|
||||
leave_type: leaveBody.leave_type,
|
||||
leave_hours: leaveBody.leave_hours,
|
||||
notes: leaveBody.notes ?? undefined,
|
||||
},
|
||||
authData.userId,
|
||||
);
|
||||
|
||||
if ('error' in result) return error(reply, result.error!, 400);
|
||||
if ("error" in result) return error(reply, result.error!, 400);
|
||||
return success(reply, { created: result.created }, 200, result.message!);
|
||||
}
|
||||
|
||||
// Punch action (arrival / departure / break_start) from Dashboard or Attendance page
|
||||
if (rawBody.punch_action) {
|
||||
const punchParsed = parseBody(AttendancePunchSchema, rawBody);
|
||||
if ('error' in punchParsed) return error(reply, punchParsed.error, 400);
|
||||
if ("error" in punchParsed) return error(reply, punchParsed.error, 400);
|
||||
const punchBody = punchParsed.data;
|
||||
|
||||
const result = await attendanceService.punchAction(authData.userId, {
|
||||
@@ -241,106 +290,132 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis
|
||||
address: punchBody.address,
|
||||
});
|
||||
|
||||
if ('error' in result) return error(reply, result.error!, 400);
|
||||
if ("error" in result) return error(reply, result.error!, 400);
|
||||
|
||||
await logAudit({
|
||||
request, authData, action: result.auditAction, entityType: 'attendance',
|
||||
entityId: result.id, description: result.auditDescription,
|
||||
request,
|
||||
authData,
|
||||
action: result.auditAction,
|
||||
entityType: "attendance",
|
||||
entityId: result.id,
|
||||
description: result.auditDescription,
|
||||
});
|
||||
return success(reply, { id: result.id }, result.status, result.message);
|
||||
}
|
||||
|
||||
// Standard attendance record creation (from admin forms)
|
||||
const stdParsed = parseBody(CreateAttendanceSchema, rawBody);
|
||||
if ('error' in stdParsed) return error(reply, stdParsed.error, 400);
|
||||
if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
|
||||
const body = stdParsed.data;
|
||||
|
||||
const result = await attendanceService.createAttendance({
|
||||
user_id: body.user_id,
|
||||
shift_date: body.shift_date,
|
||||
arrival_time: body.arrival_time,
|
||||
arrival_lat: body.arrival_lat,
|
||||
arrival_lng: body.arrival_lng,
|
||||
arrival_accuracy: body.arrival_accuracy,
|
||||
arrival_address: body.arrival_address,
|
||||
departure_time: body.departure_time,
|
||||
departure_lat: body.departure_lat,
|
||||
departure_lng: body.departure_lng,
|
||||
departure_accuracy: body.departure_accuracy,
|
||||
departure_address: body.departure_address,
|
||||
notes: body.notes,
|
||||
project_id: body.project_id,
|
||||
leave_type: body.leave_type,
|
||||
leave_hours: body.leave_hours,
|
||||
project_logs: body.project_logs,
|
||||
}, authData.userId);
|
||||
const result = await attendanceService.createAttendance(
|
||||
{
|
||||
user_id: body.user_id,
|
||||
shift_date: body.shift_date,
|
||||
arrival_time: body.arrival_time,
|
||||
arrival_lat: body.arrival_lat,
|
||||
arrival_lng: body.arrival_lng,
|
||||
arrival_accuracy: body.arrival_accuracy,
|
||||
arrival_address: body.arrival_address,
|
||||
departure_time: body.departure_time,
|
||||
departure_lat: body.departure_lat,
|
||||
departure_lng: body.departure_lng,
|
||||
departure_accuracy: body.departure_accuracy,
|
||||
departure_address: body.departure_address,
|
||||
notes: body.notes,
|
||||
project_id: body.project_id,
|
||||
leave_type: body.leave_type,
|
||||
leave_hours: body.leave_hours,
|
||||
project_logs: body.project_logs,
|
||||
},
|
||||
authData.userId,
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: 'create',
|
||||
entityType: 'attendance',
|
||||
action: "create",
|
||||
entityType: "attendance",
|
||||
entityId: result.id,
|
||||
description: `Vytvořen záznam docházky`,
|
||||
});
|
||||
|
||||
return success(reply, { id: result.id }, 201, 'Záznam byl vytvořen');
|
||||
return success(reply, { id: result.id }, 201, "Záznam byl vytvořen");
|
||||
});
|
||||
|
||||
// PUT /api/admin/attendance/:id
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateAttendanceSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateAttendanceSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const authData = request.authData!;
|
||||
const isAdmin = authData.permissions.includes('attendance.admin');
|
||||
const authData = request.authData!;
|
||||
const isAdmin = authData.permissions.includes("attendance.admin");
|
||||
|
||||
const result = await attendanceService.updateAttendance(id, {
|
||||
arrival_time: body.arrival_time,
|
||||
departure_time: body.departure_time,
|
||||
break_start: body.break_start,
|
||||
break_end: body.break_end,
|
||||
notes: body.notes ?? undefined,
|
||||
project_id: body.project_id != null ? Number(body.project_id) : (body.project_id as number | null | undefined),
|
||||
leave_type: body.leave_type,
|
||||
leave_hours: body.leave_hours != null ? Number(body.leave_hours) : (body.leave_hours as number | null | undefined),
|
||||
project_logs: body.project_logs,
|
||||
}, authData.userId, isAdmin);
|
||||
const result = await attendanceService.updateAttendance(
|
||||
id,
|
||||
{
|
||||
arrival_time: body.arrival_time,
|
||||
departure_time: body.departure_time,
|
||||
break_start: body.break_start,
|
||||
break_end: body.break_end,
|
||||
notes: body.notes ?? undefined,
|
||||
project_id:
|
||||
body.project_id != null
|
||||
? Number(body.project_id)
|
||||
: (body.project_id as number | null | undefined),
|
||||
leave_type: body.leave_type,
|
||||
leave_hours:
|
||||
body.leave_hours != null
|
||||
? Number(body.leave_hours)
|
||||
: (body.leave_hours as number | null | undefined),
|
||||
project_logs: body.project_logs,
|
||||
},
|
||||
authData.userId,
|
||||
isAdmin,
|
||||
);
|
||||
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'update',
|
||||
entityType: 'attendance',
|
||||
entityId: id,
|
||||
description: `Upraven záznam docházky`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "attendance",
|
||||
entityId: id,
|
||||
description: `Upraven záznam docházky`,
|
||||
});
|
||||
|
||||
return success(reply, { id }, 200, 'Záznam byl aktualizován');
|
||||
});
|
||||
return success(reply, { id }, 200, "Záznam byl aktualizován");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/attendance/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('attendance.admin') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("attendance.admin") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const result = await attendanceService.deleteAttendance(id);
|
||||
if ('error' in result) return error(reply, result.error!, 404);
|
||||
const result = await attendanceService.deleteAttendance(id);
|
||||
if ("error" in result) return error(reply, result.error!, 404);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'delete',
|
||||
entityType: 'attendance',
|
||||
entityId: id,
|
||||
description: `Smazán záznam docházky`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "attendance",
|
||||
entityId: id,
|
||||
description: `Smazán záznam docházky`,
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Záznam smazán');
|
||||
});
|
||||
return success(reply, null, 200, "Záznam smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { success, paginated, error } from '../../utils/response';
|
||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { success, paginated, error } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
|
||||
export default async function auditLogRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
export default async function auditLogRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("settings.audit") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.action) where.action = String(query.action);
|
||||
if (query.entity_type) where.entity_type = String(query.entity_type);
|
||||
if (query.user_id) where.user_id = Number(query.user_id);
|
||||
if (search) where.description = { contains: search };
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.action) where.action = String(query.action);
|
||||
if (query.entity_type) where.entity_type = String(query.entity_type);
|
||||
if (query.user_id) where.user_id = Number(query.user_id);
|
||||
if (search) where.description = { contains: search };
|
||||
|
||||
if (query.date_from || query.date_to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
|
||||
if (query.date_to) dateFilter.lte = new Date(String(query.date_to) + 'T23:59:59');
|
||||
where.created_at = dateFilter;
|
||||
}
|
||||
if (query.date_from || query.date_to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.date_from) dateFilter.gte = new Date(String(query.date_from));
|
||||
if (query.date_to)
|
||||
dateFilter.lte = new Date(String(query.date_to) + "T23:59:59");
|
||||
where.created_at = dateFilter;
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.audit_logs.findMany({ where, skip, take: limit, orderBy: { created_at: order } }),
|
||||
prisma.audit_logs.count({ where }),
|
||||
]);
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.audit_logs.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { created_at: order },
|
||||
}),
|
||||
prisma.audit_logs.count({ where }),
|
||||
]);
|
||||
|
||||
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
|
||||
});
|
||||
return paginated(reply, logs, buildPaginationMeta(total, page, limit));
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/audit-log/cleanup — delete old audit logs
|
||||
fastify.post('/cleanup', { preHandler: requirePermission('settings.audit') }, async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const days = body.days !== undefined ? Number(body.days) : null;
|
||||
fastify.post(
|
||||
"/cleanup",
|
||||
{ preHandler: requirePermission("settings.audit") },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const days = body.days !== undefined ? Number(body.days) : null;
|
||||
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === 'all') {
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
// days === 0 means "delete all" (from frontend "Vše" option)
|
||||
if (days === 0 || body.action === "all") {
|
||||
const result = await prisma.audit_logs.deleteMany({});
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů`);
|
||||
}
|
||||
|
||||
if (days && days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const result = await prisma.audit_logs.deleteMany({ where: { created_at: { lt: cutoff } } });
|
||||
return success(reply, null, 200, `Smazáno ${result.count} záznamů starších než ${days} dní`);
|
||||
}
|
||||
if (days && days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const result = await prisma.audit_logs.deleteMany({
|
||||
where: { created_at: { lt: cutoff } },
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
null,
|
||||
200,
|
||||
`Smazáno ${result.count} záznamů starších než ${days} dní`,
|
||||
);
|
||||
}
|
||||
|
||||
return error(reply, 'Zadejte počet dní', 400);
|
||||
});
|
||||
return error(reply, "Zadejte počet dní", 400);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +1,208 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { login, refreshAccessToken, logout, verifyAccessToken } from '../../services/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error } from '../../utils/response';
|
||||
import { config } from '../../config/env';
|
||||
import { LoginRequest, TotpVerifyRequest } from '../../types';
|
||||
import prisma from '../../config/database';
|
||||
import crypto from 'crypto';
|
||||
import { OTPAuth } from '../../utils/totp';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { LoginSchema, TotpVerifySchema } from '../../schemas/auth.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {
|
||||
login,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
verifyAccessToken,
|
||||
} from "../../services/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { config } from "../../config/env";
|
||||
import { LoginRequest, TotpVerifyRequest } from "../../types";
|
||||
import prisma from "../../config/database";
|
||||
import crypto from "crypto";
|
||||
import { OTPAuth } from "../../utils/totp";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { LoginSchema, TotpVerifySchema } from "../../schemas/auth.schema";
|
||||
|
||||
function setRefreshCookie(reply: import('fastify').FastifyReply, token: string, rememberMe: boolean) {
|
||||
function setRefreshCookie(
|
||||
reply: import("fastify").FastifyReply,
|
||||
token: string,
|
||||
rememberMe: boolean,
|
||||
) {
|
||||
const maxAge = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
reply.setCookie('refresh_token', token, {
|
||||
reply.setCookie("refresh_token", token, {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/admin',
|
||||
sameSite: "strict",
|
||||
path: "/api/admin",
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function authRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// POST /api/admin/login
|
||||
fastify.post<{ Body: LoginRequest }>('/login', {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: '1 minute',
|
||||
fastify.post<{ Body: LoginRequest }>(
|
||||
"/login",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
bodyLimit: 10240,
|
||||
}, async (request, reply) => {
|
||||
const parsed = parseBody(LoginSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const { username, password, remember_me } = parsed.data;
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(LoginSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { username, password, remember_me } = parsed.data;
|
||||
|
||||
const result = await login(username, password, remember_me, request);
|
||||
const result = await login(username, password, remember_me, request);
|
||||
|
||||
if (result.type === "error") {
|
||||
await logAudit({
|
||||
request,
|
||||
action: "login_failed",
|
||||
entityType: "user",
|
||||
description: `Neúspěšný pokus o přihlášení: ${username}`,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
if (result.type === "totp_required") {
|
||||
return success(reply, {
|
||||
totp_required: true,
|
||||
login_token: result.loginToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.type === 'error') {
|
||||
await logAudit({
|
||||
request,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
description: `Neúspěšný pokus o přihlášení: ${username}`,
|
||||
authData: result.user,
|
||||
action: "login",
|
||||
entityType: "user",
|
||||
entityId: result.user.userId,
|
||||
description: `Přihlášení uživatele ${result.user.username}`,
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
if (result.type === 'totp_required') {
|
||||
return success(reply, { totp_required: true, login_token: result.loginToken });
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: result.user,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: result.user.userId,
|
||||
description: `Přihlášení uživatele ${result.user.username}`,
|
||||
});
|
||||
|
||||
setRefreshCookie(reply, result.refreshToken, remember_me);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
});
|
||||
setRefreshCookie(reply, result.refreshToken, remember_me);
|
||||
return success(reply, {
|
||||
access_token: result.accessToken,
|
||||
user: result.user,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/login/totp
|
||||
fastify.post<{ Body: TotpVerifyRequest }>('/login/totp', { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, totp_code } = parsed.data;
|
||||
const rawBody = request.body as unknown as Record<string, unknown>;
|
||||
const rememberMe = rawBody.remember_me === true || rawBody.remember_me === 'true';
|
||||
fastify.post<{ Body: TotpVerifyRequest }>(
|
||||
"/login/totp",
|
||||
{ bodyLimit: 10240 },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, totp_code } = parsed.data;
|
||||
const rawBody = request.body as unknown as Record<string, unknown>;
|
||||
const rememberMe =
|
||||
rawBody.remember_me === true || rawBody.remember_me === "true";
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, 'Neplatný nebo expirovaný login token', 401);
|
||||
}
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_secret) {
|
||||
return error(reply, 'Uživatel nenalezen', 401);
|
||||
}
|
||||
if (!user || !user.totp_secret) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
|
||||
if (!isValid) {
|
||||
return error(reply, 'Neplatný TOTP kód', 401);
|
||||
}
|
||||
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
|
||||
if (!isValid) {
|
||||
return error(reply, "Neplatný TOTP kód", 401);
|
||||
}
|
||||
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
|
||||
});
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create tokens directly — password was already verified before TOTP was requested
|
||||
const authData = await (await import('../../services/auth')).loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return error(reply, 'Chyba načítání uživatele', 500);
|
||||
}
|
||||
// Create tokens directly — password was already verified before TOTP was requested
|
||||
const authData = await (
|
||||
await import("../../services/auth")
|
||||
).loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return error(reply, "Chyba načítání uživatele", 500);
|
||||
}
|
||||
|
||||
// Create tokens manually since password was already verified
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const accessToken = jwt.default.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
// Create tokens manually since password was already verified
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const accessToken = jwt.default.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.roles?.name ?? null,
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
|
||||
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
|
||||
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
|
||||
const refreshTokenRaw = crypto.randomBytes(32).toString("hex");
|
||||
const refreshTokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(refreshTokenRaw)
|
||||
.digest("hex");
|
||||
|
||||
const expiresIn = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
const expiresIn = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers['user-agent'] ?? null,
|
||||
},
|
||||
});
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
});
|
||||
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/refresh
|
||||
fastify.post('/refresh', { bodyLimit: 10240 }, async (request, reply) => {
|
||||
fastify.post("/refresh", { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (!refreshTokenRaw) {
|
||||
return error(reply, 'Refresh token chybí', 401);
|
||||
return error(reply, "Refresh token chybí", 401);
|
||||
}
|
||||
|
||||
const result = await refreshAccessToken(refreshTokenRaw, request);
|
||||
|
||||
if (result.type === 'error') {
|
||||
reply.clearCookie('refresh_token', { path: '/api/admin', httpOnly: true, secure: config.isProduction, sameSite: 'strict' });
|
||||
if (result.type === "error") {
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return error(reply, result.message, result.status);
|
||||
}
|
||||
|
||||
@@ -171,28 +215,33 @@ export default async function authRoutes(fastify: FastifyInstance): Promise<void
|
||||
});
|
||||
|
||||
// POST /api/admin/logout
|
||||
fastify.post('/logout', async (request, reply) => {
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (refreshTokenRaw) {
|
||||
await logout(refreshTokenRaw);
|
||||
}
|
||||
|
||||
reply.clearCookie('refresh_token', { path: '/api/admin', httpOnly: true, secure: config.isProduction, sameSite: 'strict' });
|
||||
return success(reply, null, 200, 'Odhlášení úspěšné');
|
||||
reply.clearCookie("refresh_token", {
|
||||
path: "/api/admin",
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return success(reply, null, 200, "Odhlášení úspěšné");
|
||||
});
|
||||
|
||||
// GET /api/admin/session
|
||||
fastify.get('/session', async (request, reply) => {
|
||||
fastify.get("/session", async (request, reply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return error(reply, 'Vyžadována autentizace', 401);
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return error(reply, "Vyžadována autentizace", 401);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const authData = await verifyAccessToken(token);
|
||||
|
||||
if (!authData) {
|
||||
return error(reply, 'Neplatný token', 401);
|
||||
return error(reply, "Neplatný token", 401);
|
||||
}
|
||||
|
||||
return success(reply, { user: authData });
|
||||
|
||||
@@ -1,74 +1,155 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (_request, reply) => {
|
||||
const accounts = await prisma.bank_accounts.findMany({ orderBy: { position: 'asc' } });
|
||||
return success(reply, accounts);
|
||||
});
|
||||
export default async function bankAccountsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (_request, reply) => {
|
||||
const accounts = await prisma.bank_accounts.findMany({
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
return success(reply, accounts);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
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,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
account_number: body.account_number ? String(body.account_number) : null,
|
||||
iban: body.iban ? String(body.iban) : null,
|
||||
bic: body.bic ? String(body.bic) : null,
|
||||
currency: body.currency ? String(body.currency) : 'CZK',
|
||||
is_default: body.is_default === true || body.is_default === 1 || body.is_default === '1',
|
||||
position: body.position ? Number(body.position) : 0,
|
||||
},
|
||||
});
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
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,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
account_number: body.account_number
|
||||
? String(body.account_number)
|
||||
: null,
|
||||
iban: body.iban ? String(body.iban) : null,
|
||||
bic: body.bic ? String(body.bic) : null,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
is_default:
|
||||
body.is_default === true ||
|
||||
body.is_default === 1 ||
|
||||
body.is_default === "1",
|
||||
position: body.position ? Number(body.position) : 0,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'bank_account', entityId: account.id, description: `Vytvořen bankovní účet ${account.account_name}` });
|
||||
return success(reply, { id: account.id }, 201, 'Bankovní účet vytvořen');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "bank_account",
|
||||
entityId: account.id,
|
||||
description: `Vytvořen bankovní účet ${account.account_name}`,
|
||||
});
|
||||
return success(reply, { id: account.id }, 201, "Bankovní účet vytvořen");
|
||||
},
|
||||
);
|
||||
|
||||
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 parsed = parseBody(UpdateBankAccountSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
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 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);
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Účet nenalezen", 404);
|
||||
|
||||
await prisma.bank_accounts.update({
|
||||
where: { id },
|
||||
data: {
|
||||
account_name: body.account_name !== undefined ? (body.account_name ? String(body.account_name) : null) : undefined,
|
||||
bank_name: body.bank_name !== undefined ? (body.bank_name ? String(body.bank_name) : null) : undefined,
|
||||
account_number: body.account_number !== undefined ? (body.account_number ? String(body.account_number) : null) : undefined,
|
||||
iban: body.iban !== undefined ? (body.iban ? String(body.iban) : null) : undefined,
|
||||
bic: body.bic !== undefined ? (body.bic ? String(body.bic) : null) : undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
is_default: body.is_default !== undefined ? (body.is_default === true || body.is_default === 1 || body.is_default === '1') : undefined,
|
||||
position: body.position !== undefined ? Number(body.position) : undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'bank_account', entityId: id, description: `Upraven bankovní účet` });
|
||||
return success(reply, { id }, 200, 'Bankovní účet uložen');
|
||||
});
|
||||
await prisma.bank_accounts.update({
|
||||
where: { id },
|
||||
data: {
|
||||
account_name:
|
||||
body.account_name !== undefined
|
||||
? body.account_name
|
||||
? String(body.account_name)
|
||||
: null
|
||||
: undefined,
|
||||
bank_name:
|
||||
body.bank_name !== undefined
|
||||
? body.bank_name
|
||||
? String(body.bank_name)
|
||||
: null
|
||||
: undefined,
|
||||
account_number:
|
||||
body.account_number !== undefined
|
||||
? body.account_number
|
||||
? String(body.account_number)
|
||||
: null
|
||||
: undefined,
|
||||
iban:
|
||||
body.iban !== undefined
|
||||
? body.iban
|
||||
? String(body.iban)
|
||||
: null
|
||||
: undefined,
|
||||
bic:
|
||||
body.bic !== undefined
|
||||
? body.bic
|
||||
? String(body.bic)
|
||||
: null
|
||||
: undefined,
|
||||
currency:
|
||||
body.currency !== undefined ? String(body.currency) : undefined,
|
||||
is_default:
|
||||
body.is_default !== undefined
|
||||
? body.is_default === true ||
|
||||
body.is_default === 1 ||
|
||||
body.is_default === "1"
|
||||
: undefined,
|
||||
position:
|
||||
body.position !== undefined ? Number(body.position) : undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
description: `Upraven bankovní účet`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Bankovní účet uložen");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Účet nenalezen', 404);
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.bank_accounts.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Účet nenalezen", 404);
|
||||
|
||||
await prisma.bank_accounts.delete({ where: { id } });
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'bank_account', entityId: id, description: `Smazán bankovní účet` });
|
||||
return success(reply, null, 200, 'Účet smazán');
|
||||
});
|
||||
await prisma.bank_accounts.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
description: `Smazán bankovní účet`,
|
||||
});
|
||||
return success(reply, null, 200, "Účet smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
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 {
|
||||
function encodeCustomFields(
|
||||
fields: unknown,
|
||||
fieldOrder: unknown,
|
||||
): string | null {
|
||||
const f = Array.isArray(fields) ? fields : [];
|
||||
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
|
||||
if (f.length === 0 && o.length === 0) return null;
|
||||
@@ -16,13 +19,24 @@ function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null
|
||||
}
|
||||
|
||||
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
|
||||
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; supplier_field_order: string[] } {
|
||||
function decodeCustomFields(raw: string | null): {
|
||||
custom_fields: unknown[];
|
||||
supplier_field_order: string[];
|
||||
} {
|
||||
if (!raw) return { custom_fields: [], supplier_field_order: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// PHP format: { fields: [...], field_order: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
|
||||
return { custom_fields: parsed.fields || [], supplier_field_order: parsed.field_order || [] };
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
"fields" in parsed
|
||||
) {
|
||||
return {
|
||||
custom_fields: parsed.fields || [],
|
||||
supplier_field_order: parsed.field_order || [],
|
||||
};
|
||||
}
|
||||
// Legacy TS format: raw array
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -34,47 +48,66 @@ function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; sup
|
||||
}
|
||||
}
|
||||
|
||||
export default async function companySettingsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function companySettingsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
|
||||
// GET /api/admin/company-settings/logo
|
||||
fastify.get('/logo', { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({ select: { logo_data: true } });
|
||||
if (!settings?.logo_data) return error(reply, 'Logo nenalezeno', 404);
|
||||
fastify.get("/logo", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { logo_data: true },
|
||||
});
|
||||
if (!settings?.logo_data) return error(reply, "Logo nenalezeno", 404);
|
||||
|
||||
// Detect image type from magic bytes
|
||||
const buf = settings.logo_data;
|
||||
let mime = 'image/png';
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
|
||||
return reply.type(mime).send(buf);
|
||||
});
|
||||
|
||||
// POST /api/admin/company-settings/logo
|
||||
fastify.post('/logo', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, 'Nebyl nahrán žádný soubor', 400);
|
||||
fastify.post(
|
||||
"/logo",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor", 400);
|
||||
|
||||
const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
return error(reply, 'Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP', 400);
|
||||
}
|
||||
const allowed = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!allowed.includes(file.mimetype)) {
|
||||
return error(
|
||||
reply,
|
||||
"Nepodporovaný formát. Povoleno: PNG, JPG, GIF, WebP",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, 'Nastavení nenalezeno', 404);
|
||||
const buffer = await file.toBuffer();
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, "Nastavení nenalezeno", 404);
|
||||
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
|
||||
});
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Nahráno logo' });
|
||||
return success(reply, null, 200, 'Logo nahráno');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "company_settings",
|
||||
entityId: existing.id,
|
||||
description: "Nahráno logo",
|
||||
});
|
||||
return success(reply, null, 200, "Logo nahráno");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get('/', { preHandler: requireAuth }, async (_request, reply) => {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
let settings = await prisma.company_settings.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -102,9 +135,9 @@ export default async function companySettingsRoutes(fastify: FastifyInstance): P
|
||||
if (!settings) {
|
||||
settings = await prisma.company_settings.create({
|
||||
data: {
|
||||
company_name: '',
|
||||
quotation_prefix: 'N',
|
||||
default_currency: 'EUR',
|
||||
company_name: "",
|
||||
quotation_prefix: "N",
|
||||
default_currency: "EUR",
|
||||
default_vat_rate: 21.0,
|
||||
},
|
||||
select: {
|
||||
@@ -136,49 +169,97 @@ export default async function companySettingsRoutes(fastify: FastifyInstance): P
|
||||
where: { id: settings.id },
|
||||
select: { logo_data: true },
|
||||
});
|
||||
const has_logo = !!(logoCheck?.logo_data);
|
||||
const has_logo = !!logoCheck?.logo_data;
|
||||
|
||||
const { custom_fields, supplier_field_order } = decodeCustomFields(settings.custom_fields as string | null);
|
||||
const { custom_fields, supplier_field_order } = decodeCustomFields(
|
||||
settings.custom_fields as string | null,
|
||||
);
|
||||
|
||||
return success(reply, { ...settings, custom_fields, supplier_field_order, has_logo });
|
||||
return success(reply, {
|
||||
...settings,
|
||||
custom_fields,
|
||||
supplier_field_order,
|
||||
has_logo,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.put('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
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);
|
||||
const existing = await prisma.company_settings.findFirst();
|
||||
if (!existing) return error(reply, "Nastavení nenalezeno", 404);
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = ['company_name', 'street', 'city', 'postal_code', 'country', 'company_id', 'vat_id', 'quotation_prefix', 'default_currency', 'order_type_code', 'invoice_type_code'];
|
||||
const bodyRec = body as Record<string, unknown>;
|
||||
for (const f of strFields) {
|
||||
if (bodyRec[f] !== undefined) data[f] = bodyRec[f] ? String(bodyRec[f]) : null;
|
||||
}
|
||||
if (body.default_vat_rate !== undefined) data.default_vat_rate = Number(body.default_vat_rate);
|
||||
if (body.require_2fa !== undefined) data.require_2fa = body.require_2fa === true || body.require_2fa === 1 || body.require_2fa === '1';
|
||||
if (body.custom_fields !== undefined || body.supplier_field_order !== undefined) {
|
||||
let existingFields: unknown[] = [];
|
||||
let existingOrder: unknown[] = [];
|
||||
if (existing.custom_fields) {
|
||||
try {
|
||||
const parsed = JSON.parse(existing.custom_fields);
|
||||
existingFields = parsed?.fields || [];
|
||||
existingOrder = parsed?.field_order || [];
|
||||
} catch { /* invalid JSON, use defaults */ }
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = [
|
||||
"company_name",
|
||||
"street",
|
||||
"city",
|
||||
"postal_code",
|
||||
"country",
|
||||
"company_id",
|
||||
"vat_id",
|
||||
"quotation_prefix",
|
||||
"default_currency",
|
||||
"order_type_code",
|
||||
"invoice_type_code",
|
||||
];
|
||||
const bodyRec = body as Record<string, unknown>;
|
||||
for (const f of strFields) {
|
||||
if (bodyRec[f] !== undefined)
|
||||
data[f] = bodyRec[f] ? String(bodyRec[f]) : null;
|
||||
}
|
||||
data.custom_fields = encodeCustomFields(
|
||||
body.custom_fields !== undefined ? body.custom_fields : existingFields,
|
||||
body.supplier_field_order !== undefined ? body.supplier_field_order : existingOrder,
|
||||
);
|
||||
}
|
||||
data.sync_version = (existing.sync_version ?? 0) + 1;
|
||||
if (body.default_vat_rate !== undefined)
|
||||
data.default_vat_rate = Number(body.default_vat_rate);
|
||||
if (body.require_2fa !== undefined)
|
||||
data.require_2fa =
|
||||
body.require_2fa === true ||
|
||||
body.require_2fa === 1 ||
|
||||
body.require_2fa === "1";
|
||||
if (
|
||||
body.custom_fields !== undefined ||
|
||||
body.supplier_field_order !== undefined
|
||||
) {
|
||||
let existingFields: unknown[] = [];
|
||||
let existingOrder: unknown[] = [];
|
||||
if (existing.custom_fields) {
|
||||
try {
|
||||
const parsed = JSON.parse(existing.custom_fields);
|
||||
existingFields = parsed?.fields || [];
|
||||
existingOrder = parsed?.field_order || [];
|
||||
} catch {
|
||||
/* invalid JSON, use defaults */
|
||||
}
|
||||
}
|
||||
data.custom_fields = encodeCustomFields(
|
||||
body.custom_fields !== undefined
|
||||
? body.custom_fields
|
||||
: existingFields,
|
||||
body.supplier_field_order !== undefined
|
||||
? body.supplier_field_order
|
||||
: existingOrder,
|
||||
);
|
||||
}
|
||||
data.sync_version = (existing.sync_version ?? 0) + 1;
|
||||
|
||||
await prisma.company_settings.update({ where: { id: existing.id }, data });
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'company_settings', entityId: existing.id, description: 'Upraveno firemní nastavení' });
|
||||
return success(reply, { id: existing.id }, 200, 'Nastavení bylo uloženo');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "company_settings",
|
||||
entityId: existing.id,
|
||||
description: "Upraveno firemní nastavení",
|
||||
});
|
||||
return success(reply, { id: existing.id }, 200, "Nastavení bylo uloženo");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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 { CreateCustomerSchema, UpdateCustomerSchema } from '../../schemas/customers.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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 {
|
||||
CreateCustomerSchema,
|
||||
UpdateCustomerSchema,
|
||||
} from "../../schemas/customers.schema";
|
||||
|
||||
const ALLOWED_SORT_FIELDS = ['id', 'name', 'company_id', 'city', 'country'];
|
||||
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
|
||||
|
||||
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */
|
||||
function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null {
|
||||
function encodeCustomFields(
|
||||
fields: unknown,
|
||||
fieldOrder: unknown,
|
||||
): string | null {
|
||||
const f = Array.isArray(fields) ? fields : [];
|
||||
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
|
||||
if (f.length === 0 && o.length === 0) return null;
|
||||
@@ -18,13 +24,24 @@ function encodeCustomFields(fields: unknown, fieldOrder: unknown): string | null
|
||||
}
|
||||
|
||||
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
|
||||
function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; customer_field_order: string[] } {
|
||||
function decodeCustomFields(raw: string | null): {
|
||||
custom_fields: unknown[];
|
||||
customer_field_order: string[];
|
||||
} {
|
||||
if (!raw) return { custom_fields: [], customer_field_order: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// PHP format: { fields: [...], field_order: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'fields' in parsed) {
|
||||
return { custom_fields: parsed.fields || [], customer_field_order: parsed.field_order || [] };
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
"fields" in parsed
|
||||
) {
|
||||
return {
|
||||
custom_fields: parsed.fields || [],
|
||||
customer_field_order: parsed.field_order || [],
|
||||
};
|
||||
}
|
||||
// Legacy TS format: raw array
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -36,111 +53,221 @@ function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; cus
|
||||
}
|
||||
}
|
||||
|
||||
export default async function customersRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record<string, unknown>);
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'name';
|
||||
export default async function customersRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name";
|
||||
|
||||
const where = search
|
||||
? { OR: [{ name: { contains: search } }, { company_id: { contains: search } }] }
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customers.findMany({
|
||||
where, skip, take: limit, orderBy: { [sortField]: order },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = customers.map(c => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(c.custom_fields);
|
||||
return { ...c, custom_fields, customer_field_order, quotation_count: c._count?.quotations ?? 0 };
|
||||
const enriched = customers.map((c) => {
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
c.custom_fields,
|
||||
);
|
||||
return {
|
||||
...c,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
quotation_count: c._count?.quotations ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
|
||||
});
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const customer = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!customer) return error(reply, 'Zákazník nenalezen', 404);
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(customer.custom_fields);
|
||||
return success(reply, { ...customer, custom_fields, customer_field_order });
|
||||
});
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateCustomerSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const name = body.name;
|
||||
|
||||
const customer = await prisma.customers.create({
|
||||
data: {
|
||||
name,
|
||||
street: body.street ? String(body.street) : null,
|
||||
city: body.city ? String(body.city) : null,
|
||||
postal_code: body.postal_code ? String(body.postal_code) : null,
|
||||
country: body.country ? String(body.country) : null,
|
||||
company_id: body.company_id ? String(body.company_id) : null,
|
||||
vat_id: body.vat_id ? String(body.vat_id) : null,
|
||||
custom_fields: encodeCustomFields(body.custom_fields, body.customer_field_order),
|
||||
},
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: enriched,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'customer', entityId: customer.id, description: `Vytvořen zákazník ${customer.name}` });
|
||||
return success(reply, { id: customer.id }, 201, 'Zákazník byl vytvořen');
|
||||
});
|
||||
|
||||
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 parsed = parseBody(UpdateCustomerSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const customer = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!customer) return error(reply, "Zákazník nenalezen", 404);
|
||||
const { custom_fields, customer_field_order } = decodeCustomFields(
|
||||
customer.custom_fields,
|
||||
);
|
||||
return success(reply, {
|
||||
...customer,
|
||||
custom_fields,
|
||||
customer_field_order,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("customers.manage") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateCustomerSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
await prisma.customers.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
street: body.street !== undefined ? (body.street ? String(body.street) : null) : undefined,
|
||||
city: body.city !== undefined ? (body.city ? String(body.city) : null) : undefined,
|
||||
postal_code: body.postal_code !== undefined ? (body.postal_code ? String(body.postal_code) : null) : undefined,
|
||||
country: body.country !== undefined ? (body.country ? String(body.country) : null) : undefined,
|
||||
company_id: body.company_id !== undefined ? (body.company_id ? String(body.company_id) : null) : undefined,
|
||||
vat_id: body.vat_id !== undefined ? (body.vat_id ? String(body.vat_id) : null) : undefined,
|
||||
custom_fields: body.custom_fields !== undefined ? encodeCustomFields(body.custom_fields, body.customer_field_order) : undefined,
|
||||
},
|
||||
});
|
||||
const name = body.name;
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'customer', entityId: id, description: `Upraven zákazník ${existing.name}` });
|
||||
return success(reply, { id }, 200, 'Zákazník byl uložen');
|
||||
});
|
||||
const customer = await prisma.customers.create({
|
||||
data: {
|
||||
name,
|
||||
street: body.street ? String(body.street) : null,
|
||||
city: body.city ? String(body.city) : null,
|
||||
postal_code: body.postal_code ? String(body.postal_code) : null,
|
||||
country: body.country ? String(body.country) : null,
|
||||
company_id: body.company_id ? String(body.company_id) : null,
|
||||
vat_id: body.vat_id ? String(body.vat_id) : null,
|
||||
custom_fields: encodeCustomFields(
|
||||
body.custom_fields,
|
||||
body.customer_field_order,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('customers.manage') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Zákazník nenalezen', 404);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "customer",
|
||||
entityId: customer.id,
|
||||
description: `Vytvořen zákazník ${customer.name}`,
|
||||
});
|
||||
return success(reply, { id: customer.id }, 201, "Zákazník byl vytvořen");
|
||||
},
|
||||
);
|
||||
|
||||
// Check for FK references before deleting
|
||||
const [quotCount, orderCount, invoiceCount, projectCount] = await Promise.all([
|
||||
prisma.quotations.count({ where: { customer_id: id } }),
|
||||
prisma.orders.count({ where: { customer_id: id } }),
|
||||
prisma.invoices.count({ where: { customer_id: id } }),
|
||||
prisma.projects.count({ where: { customer_id: id } }),
|
||||
]);
|
||||
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
|
||||
return error(reply, 'Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty', 400);
|
||||
}
|
||||
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 parsed = parseBody(UpdateCustomerSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
await prisma.customers.delete({ where: { id } });
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'customer', entityId: id, description: `Smazán zákazník ${existing.name}` });
|
||||
return success(reply, null, 200, 'Zákazník smazán');
|
||||
});
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Zákazník nenalezen", 404);
|
||||
|
||||
await prisma.customers.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
street:
|
||||
body.street !== undefined
|
||||
? body.street
|
||||
? String(body.street)
|
||||
: null
|
||||
: undefined,
|
||||
city:
|
||||
body.city !== undefined
|
||||
? body.city
|
||||
? String(body.city)
|
||||
: null
|
||||
: undefined,
|
||||
postal_code:
|
||||
body.postal_code !== undefined
|
||||
? body.postal_code
|
||||
? String(body.postal_code)
|
||||
: null
|
||||
: undefined,
|
||||
country:
|
||||
body.country !== undefined
|
||||
? body.country
|
||||
? String(body.country)
|
||||
: null
|
||||
: undefined,
|
||||
company_id:
|
||||
body.company_id !== undefined
|
||||
? body.company_id
|
||||
? String(body.company_id)
|
||||
: null
|
||||
: undefined,
|
||||
vat_id:
|
||||
body.vat_id !== undefined
|
||||
? body.vat_id
|
||||
? String(body.vat_id)
|
||||
: null
|
||||
: undefined,
|
||||
custom_fields:
|
||||
body.custom_fields !== undefined
|
||||
? encodeCustomFields(
|
||||
body.custom_fields,
|
||||
body.customer_field_order,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
description: `Upraven zákazník ${existing.name}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Zákazník byl uložen");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("customers.manage") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.customers.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Zákazník nenalezen", 404);
|
||||
|
||||
// Check for FK references before deleting
|
||||
const [quotCount, orderCount, invoiceCount, projectCount] =
|
||||
await Promise.all([
|
||||
prisma.quotations.count({ where: { customer_id: id } }),
|
||||
prisma.orders.count({ where: { customer_id: id } }),
|
||||
prisma.invoices.count({ where: { customer_id: id } }),
|
||||
prisma.projects.count({ where: { customer_id: id } }),
|
||||
]);
|
||||
if (quotCount + orderCount + invoiceCount + projectCount > 0) {
|
||||
return error(
|
||||
reply,
|
||||
"Zákazníka nelze smazat — existují propojené nabídky, objednávky, faktury nebo projekty",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.customers.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
description: `Smazán zákazník ${existing.name}`,
|
||||
});
|
||||
return success(reply, null, 200, "Zákazník smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
import { success } from '../../utils/response';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth } from "../../middleware/auth";
|
||||
import { success } from "../../utils/response";
|
||||
|
||||
export default async function dashboardRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
export default async function dashboardRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
const todayStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
);
|
||||
const todayEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 1,
|
||||
);
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const authData = request.authData!;
|
||||
@@ -18,65 +28,87 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// My shift — always available for authenticated users with attendance.record
|
||||
if (has('attendance.record')) {
|
||||
if (has("attendance.record")) {
|
||||
const myShift = await prisma.attendance.findFirst({
|
||||
where: { user_id: userId, arrival_time: { not: null }, departure_time: null },
|
||||
orderBy: { created_at: 'desc' },
|
||||
where: {
|
||||
user_id: userId,
|
||||
arrival_time: { not: null },
|
||||
departure_time: null,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
result.my_shift = { has_ongoing: myShift !== null };
|
||||
}
|
||||
|
||||
// Attendance admin — only for attendance.admin
|
||||
if (has('attendance.admin')) {
|
||||
if (has("attendance.admin")) {
|
||||
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
shift_date: { gte: todayStart, lt: todayEnd },
|
||||
OR: [{ leave_type: null }, { leave_type: 'work' }],
|
||||
OR: [{ leave_type: null }, { leave_type: "work" }],
|
||||
},
|
||||
include: { users: { select: { id: true, first_name: true, last_name: true } } },
|
||||
orderBy: { arrival_time: 'asc' },
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
},
|
||||
orderBy: { arrival_time: "asc" },
|
||||
}),
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
shift_date: { gte: todayStart, lt: todayEnd },
|
||||
leave_type: { in: ['vacation', 'sick', 'holiday', 'unpaid'] },
|
||||
leave_type: { in: ["vacation", "sick", "holiday", "unpaid"] },
|
||||
},
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
},
|
||||
include: { users: { select: { id: true, first_name: true, last_name: true } } },
|
||||
}),
|
||||
prisma.users.count({ where: { is_active: true } }),
|
||||
]);
|
||||
|
||||
const userAttendanceMap = new Map<number, typeof todayAttendance[0]>();
|
||||
const userAttendanceMap = new Map<number, (typeof todayAttendance)[0]>();
|
||||
for (const a of todayAttendance) {
|
||||
const existing = userAttendanceMap.get(a.users.id);
|
||||
if (!existing || (a.arrival_time && existing.arrival_time && a.arrival_time > existing.arrival_time)) {
|
||||
if (
|
||||
!existing ||
|
||||
(a.arrival_time &&
|
||||
existing.arrival_time &&
|
||||
a.arrival_time > existing.arrival_time)
|
||||
) {
|
||||
userAttendanceMap.set(a.users.id, a);
|
||||
}
|
||||
}
|
||||
|
||||
let presentCount = 0;
|
||||
const attendanceUsers: Array<{
|
||||
user_id: number; name: string; initials: string;
|
||||
status: string; arrived_at: string | null; leave_type?: string;
|
||||
user_id: number;
|
||||
name: string;
|
||||
initials: string;
|
||||
status: string;
|
||||
arrived_at: string | null;
|
||||
leave_type?: string;
|
||||
}> = [];
|
||||
|
||||
for (const a of userAttendanceMap.values()) {
|
||||
const user = a.users;
|
||||
const firstInitial = user.first_name?.charAt(0) ?? '';
|
||||
const lastInitial = user.last_name?.charAt(0) ?? '';
|
||||
let status = 'out';
|
||||
const firstInitial = user.first_name?.charAt(0) ?? "";
|
||||
const lastInitial = user.last_name?.charAt(0) ?? "";
|
||||
let status = "out";
|
||||
if (a.arrival_time) {
|
||||
if (a.departure_time) status = 'out';
|
||||
else if (a.break_start && !a.break_end) status = 'away';
|
||||
else { status = 'in'; presentCount++; }
|
||||
if (a.departure_time) status = "out";
|
||||
else if (a.break_start && !a.break_end) status = "away";
|
||||
else {
|
||||
status = "in";
|
||||
presentCount++;
|
||||
}
|
||||
}
|
||||
attendanceUsers.push({
|
||||
user_id: user.id,
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
|
||||
status,
|
||||
arrived_at: a.arrival_time ? `${String(a.arrival_time.getHours()).padStart(2, '0')}:${String(a.arrival_time.getMinutes()).padStart(2, '0')}` : null,
|
||||
arrived_at: a.arrival_time
|
||||
? `${String(a.arrival_time.getHours()).padStart(2, "0")}:${String(a.arrival_time.getMinutes()).padStart(2, "0")}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,10 +120,11 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
||||
attendanceUsers.push({
|
||||
user_id: user.id,
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
initials: `${user.first_name?.charAt(0) ?? ''}${user.last_name?.charAt(0) ?? ''}`.toUpperCase(),
|
||||
status: 'leave',
|
||||
initials:
|
||||
`${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
|
||||
status: "leave",
|
||||
arrived_at: null,
|
||||
leave_type: (a.leave_type as string) || 'vacation',
|
||||
leave_type: (a.leave_type as string) || "vacation",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,39 +138,49 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
||||
}
|
||||
|
||||
// Offers — only for offers.view
|
||||
if (has('offers.view')) {
|
||||
const [openCount, convertedCount, expiredCount, createdThisMonth] = await Promise.all([
|
||||
prisma.quotations.count({ where: { status: 'active' } }),
|
||||
prisma.quotations.count({ where: { status: 'converted' } }),
|
||||
prisma.quotations.count({ where: { status: 'expired' } }),
|
||||
prisma.quotations.count({ where: { created_at: { gte: monthStart, lt: monthEnd } } }),
|
||||
]);
|
||||
result.offers = { open_count: openCount, converted_count: convertedCount, expired_count: expiredCount, created_this_month: createdThisMonth };
|
||||
if (has("offers.view")) {
|
||||
const [openCount, convertedCount, expiredCount, createdThisMonth] =
|
||||
await Promise.all([
|
||||
prisma.quotations.count({ where: { status: "active" } }),
|
||||
prisma.quotations.count({ where: { status: "converted" } }),
|
||||
prisma.quotations.count({ where: { status: "expired" } }),
|
||||
prisma.quotations.count({
|
||||
where: { created_at: { gte: monthStart, lt: monthEnd } },
|
||||
}),
|
||||
]);
|
||||
result.offers = {
|
||||
open_count: openCount,
|
||||
converted_count: convertedCount,
|
||||
expired_count: expiredCount,
|
||||
created_this_month: createdThisMonth,
|
||||
};
|
||||
}
|
||||
|
||||
// Projects — only for projects.view
|
||||
if (has('projects.view')) {
|
||||
if (has("projects.view")) {
|
||||
const [activeCount, activeList] = await Promise.all([
|
||||
prisma.projects.count({ where: { status: 'aktivni' } }),
|
||||
prisma.projects.count({ where: { status: "aktivni" } }),
|
||||
prisma.projects.findMany({
|
||||
where: { status: 'aktivni' },
|
||||
where: { status: "aktivni" },
|
||||
include: { customers: { select: { name: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
result.active_projects = activeCount;
|
||||
result.projects = {
|
||||
active_projects: activeList.map(p => ({
|
||||
id: p.id, name: p.name ?? '', customer_name: p.customers?.name ?? null,
|
||||
active_projects: activeList.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name ?? "",
|
||||
customer_name: p.customers?.name ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Invoices — only for invoices.view
|
||||
if (has('invoices.view')) {
|
||||
if (has("invoices.view")) {
|
||||
const [unpaidCount, issuedThisMonth] = await Promise.all([
|
||||
prisma.invoices.count({ where: { status: 'issued' } }),
|
||||
prisma.invoices.count({ where: { status: "issued" } }),
|
||||
prisma.invoices.findMany({
|
||||
where: { issue_date: { gte: monthStart, lt: monthEnd } },
|
||||
include: { invoice_items: true },
|
||||
@@ -146,48 +189,70 @@ export default async function dashboardRoutes(fastify: FastifyInstance): Promise
|
||||
|
||||
const revenueByCurrency: Record<string, number> = {};
|
||||
for (const inv of issuedThisMonth) {
|
||||
const currency = inv.currency ?? 'CZK';
|
||||
const currency = inv.currency ?? "CZK";
|
||||
let total = 0;
|
||||
for (const item of inv.invoice_items) {
|
||||
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
total +=
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
revenueByCurrency[currency] = (revenueByCurrency[currency] ?? 0) + total;
|
||||
revenueByCurrency[currency] =
|
||||
(revenueByCurrency[currency] ?? 0) + total;
|
||||
}
|
||||
|
||||
result.invoices = {
|
||||
revenue_this_month: Object.entries(revenueByCurrency).map(([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100, currency,
|
||||
})),
|
||||
revenue_this_month: Object.entries(revenueByCurrency).map(
|
||||
([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}),
|
||||
),
|
||||
unpaid_count: unpaidCount,
|
||||
revenue_czk: revenueByCurrency['CZK'] != null ? Math.round(revenueByCurrency['CZK'] * 100) / 100 : null,
|
||||
revenue_czk:
|
||||
revenueByCurrency["CZK"] != null
|
||||
? Math.round(revenueByCurrency["CZK"] * 100) / 100
|
||||
: null,
|
||||
};
|
||||
result.unpaid_invoices = unpaidCount;
|
||||
}
|
||||
|
||||
// Orders — only for orders.view
|
||||
if (has('orders.view')) {
|
||||
result.pending_orders = await prisma.orders.count({ where: { status: 'prijata' } });
|
||||
if (has("orders.view")) {
|
||||
result.pending_orders = await prisma.orders.count({
|
||||
where: { status: "prijata" },
|
||||
});
|
||||
}
|
||||
|
||||
// Leave pending — only for attendance.approve
|
||||
if (has('attendance.approve')) {
|
||||
const count = await prisma.leave_requests.count({ where: { status: 'pending' } });
|
||||
if (has("attendance.approve")) {
|
||||
const count = await prisma.leave_requests.count({
|
||||
where: { status: "pending" },
|
||||
});
|
||||
result.leave_pending = { count };
|
||||
result.pending_leave_requests = count;
|
||||
}
|
||||
|
||||
// Recent activity — only for settings.audit (admin)
|
||||
if (has('settings.audit')) {
|
||||
if (has("settings.audit")) {
|
||||
const logs = await prisma.audit_logs.findMany({
|
||||
orderBy: { created_at: 'desc' },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 8,
|
||||
where: { action: { in: ['create', 'update', 'delete', 'login'] } },
|
||||
select: { id: true, action: true, entity_type: true, description: true, username: true, created_at: true },
|
||||
where: { action: { in: ["create", "update", "delete", "login"] } },
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
entity_type: true,
|
||||
description: true,
|
||||
username: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
result.recent_activity = logs.map(log => ({
|
||||
id: log.id, action: log.action, entity_type: log.entity_type ?? '',
|
||||
description: log.description ?? '', username: log.username ?? null,
|
||||
created_at: log.created_at ? log.created_at.toISOString() : '',
|
||||
result.recent_activity = logs.map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
entity_type: log.entity_type ?? "",
|
||||
description: log.description ?? "",
|
||||
username: log.username ?? null,
|
||||
created_at: log.created_at ? log.created_at.toISOString() : "",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +1,85 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import QRCode from 'qrcode';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function formatNum(n: number, decimals = 2): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, '');
|
||||
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, '');
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(/( )/g, ' ');
|
||||
let prev = '';
|
||||
s = s.replace(/( )/g, " ");
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult { name: string; lines: string[] }
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
tObj: Record<string, string>,
|
||||
): AddressResult {
|
||||
if (!entity) return { name: '', lines: [] };
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
const nameKey = isSupplier ? 'company_name' : 'name';
|
||||
const name = String(entity[nameKey] || '');
|
||||
const nameKey = isSupplier ? "company_name" : "name";
|
||||
const name = String(entity[nameKey] || "");
|
||||
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = [];
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
|
||||
[];
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData = ((parsed as Record<string, unknown>).fields as typeof cfData) || [];
|
||||
fieldOrder = ((parsed as Record<string, unknown>).field_order || (parsed as Record<string, unknown>).fieldOrder) as string[] | null;
|
||||
cfData =
|
||||
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
|
||||
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
|
||||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
|
||||
} else if (Array.isArray(parsed)) {
|
||||
cfData = parsed;
|
||||
}
|
||||
@@ -72,29 +88,37 @@ function buildAddressLines(
|
||||
|
||||
if (Array.isArray(fieldOrder)) {
|
||||
const legacyMap: Record<string, string> = {
|
||||
Name: 'name', CompanyName: 'company_name',
|
||||
Street: 'street', CityPostal: 'city_postal',
|
||||
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id',
|
||||
Name: "name",
|
||||
CompanyName: "company_name",
|
||||
Street: "street",
|
||||
CityPostal: "city_postal",
|
||||
Country: "country",
|
||||
CompanyId: "company_id",
|
||||
VatId: "vat_id",
|
||||
};
|
||||
fieldOrder = fieldOrder.map(k => legacyMap[k] || k);
|
||||
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
|
||||
}
|
||||
|
||||
const fieldMap: Record<string, string> = {};
|
||||
if (name) fieldMap[nameKey] = name;
|
||||
if (entity.street) fieldMap.street = String(entity.street);
|
||||
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String);
|
||||
const cityPostal = cityParts.join(' ').trim();
|
||||
const cityParts = [entity.city || "", entity.postal_code || ""]
|
||||
.filter(Boolean)
|
||||
.map(String);
|
||||
const cityPostal = cityParts.join(" ").trim();
|
||||
if (cityPostal) fieldMap.city_postal = cityPostal;
|
||||
if (entity.country) fieldMap.country = String(entity.country);
|
||||
if (entity.company_id) fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||
if (entity.company_id)
|
||||
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
||||
|
||||
cfData.forEach((cf, i) => {
|
||||
const cfName = (cf.name || '').trim();
|
||||
const cfValue = (cf.value || '').trim();
|
||||
const cfName = (cf.name || "").trim();
|
||||
const cfValue = (cf.value || "").trim();
|
||||
const showLabel = cf.showLabel !== false;
|
||||
if (cfValue) {
|
||||
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
|
||||
fieldMap[`custom_${i}`] =
|
||||
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,251 +146,282 @@ function buildAddressLines(
|
||||
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
cs: {
|
||||
title: 'Faktura',
|
||||
heading: 'FAKTURA - DAŇOVÝ DOKLAD č.',
|
||||
supplier: 'Dodavatel',
|
||||
customer: 'Odběratel',
|
||||
bank: 'Banka:',
|
||||
swift: 'SWIFT:',
|
||||
iban: 'IBAN:',
|
||||
account_no: 'Číslo účtu:',
|
||||
var_symbol: 'Variabilní s.:',
|
||||
const_symbol: 'Konstantní s.:',
|
||||
order_no: 'Objednávka č.:',
|
||||
issue_date: 'Datum vystavení:',
|
||||
due_date: 'Datum splatnosti:',
|
||||
tax_date: 'Datum uskutečnění plnění:',
|
||||
payment_method: 'Forma úhrady:',
|
||||
billing: 'Fakturujeme Vám za:',
|
||||
col_no: 'Č.',
|
||||
col_desc: 'Popis',
|
||||
col_qty: 'Množství',
|
||||
col_unit_price: 'Jedn. cena',
|
||||
col_price: 'Cena',
|
||||
col_vat_pct: '%DPH',
|
||||
col_vat: 'DPH',
|
||||
col_total: 'Celkem',
|
||||
subtotal: 'Mezisoučet:',
|
||||
vat_label: 'DPH',
|
||||
total: 'Celkem k úhradě',
|
||||
amounts_in: 'Částky jsou uvedeny v',
|
||||
notes: 'Poznámky',
|
||||
issued_by: 'Vystavil:',
|
||||
notice: 'Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti'
|
||||
+ ' uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp.'
|
||||
+ ' zákonné výši a smluvní pokutu (byla-li sjednána).',
|
||||
vat_recap: 'Rekapitulace DPH v Kč:',
|
||||
vat_base: 'Základ v Kč',
|
||||
vat_rate: 'Sazba',
|
||||
vat_amount: 'DPH v Kč',
|
||||
vat_with_total: 'Celkem s DPH v Kč',
|
||||
received_by: 'Převzal:',
|
||||
stamp: 'Razítko:',
|
||||
ico: 'IČ: ',
|
||||
dic: 'DIČ: ',
|
||||
title: "Faktura",
|
||||
heading: "FAKTURA - DAŇOVÝ DOKLAD č.",
|
||||
supplier: "Dodavatel",
|
||||
customer: "Odběratel",
|
||||
bank: "Banka:",
|
||||
swift: "SWIFT:",
|
||||
iban: "IBAN:",
|
||||
account_no: "Číslo účtu:",
|
||||
var_symbol: "Variabilní s.:",
|
||||
const_symbol: "Konstantní s.:",
|
||||
order_no: "Objednávka č.:",
|
||||
issue_date: "Datum vystavení:",
|
||||
due_date: "Datum splatnosti:",
|
||||
tax_date: "Datum uskutečnění plnění:",
|
||||
payment_method: "Forma úhrady:",
|
||||
billing: "Fakturujeme Vám za:",
|
||||
col_no: "Č.",
|
||||
col_desc: "Popis",
|
||||
col_qty: "Množství",
|
||||
col_unit_price: "Jedn. cena",
|
||||
col_price: "Cena",
|
||||
col_vat_pct: "%DPH",
|
||||
col_vat: "DPH",
|
||||
col_total: "Celkem",
|
||||
subtotal: "Mezisoučet:",
|
||||
vat_label: "DPH",
|
||||
total: "Celkem k úhradě",
|
||||
amounts_in: "Částky jsou uvedeny v",
|
||||
notes: "Poznámky",
|
||||
issued_by: "Vystavil:",
|
||||
notice:
|
||||
"Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti" +
|
||||
" uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp." +
|
||||
" zákonné výši a smluvní pokutu (byla-li sjednána).",
|
||||
vat_recap: "Rekapitulace DPH v Kč:",
|
||||
vat_base: "Základ v Kč",
|
||||
vat_rate: "Sazba",
|
||||
vat_amount: "DPH v Kč",
|
||||
vat_with_total: "Celkem s DPH v Kč",
|
||||
received_by: "Převzal:",
|
||||
stamp: "Razítko:",
|
||||
ico: "IČ: ",
|
||||
dic: "DIČ: ",
|
||||
},
|
||||
en: {
|
||||
title: 'Invoice',
|
||||
heading: 'INVOICE - TAX DOCUMENT No.',
|
||||
supplier: 'Supplier',
|
||||
customer: 'Customer',
|
||||
bank: 'Bank:',
|
||||
swift: 'SWIFT:',
|
||||
iban: 'IBAN:',
|
||||
account_no: 'Account No.:',
|
||||
var_symbol: 'Variable symbol:',
|
||||
const_symbol: 'Constant symbol:',
|
||||
order_no: 'Order No.:',
|
||||
issue_date: 'Issue date:',
|
||||
due_date: 'Due date:',
|
||||
tax_date: 'Tax point date:',
|
||||
payment_method: 'Payment method:',
|
||||
billing: 'We invoice you for:',
|
||||
col_no: 'No.',
|
||||
col_desc: 'Description',
|
||||
col_qty: 'Quantity',
|
||||
col_unit_price: 'Unit price',
|
||||
col_price: 'Price',
|
||||
col_vat_pct: 'VAT%',
|
||||
col_vat: 'VAT',
|
||||
col_total: 'Total',
|
||||
subtotal: 'Subtotal:',
|
||||
vat_label: 'VAT',
|
||||
total: 'Total to pay',
|
||||
amounts_in: 'Amounts are in',
|
||||
notes: 'Notes',
|
||||
issued_by: 'Issued by:',
|
||||
notice: 'Please note that in case of late payment, we will charge default interest'
|
||||
+ ' at the agreed or statutory rate and a contractual penalty (if agreed).',
|
||||
vat_recap: 'VAT recapitulation in CZK:',
|
||||
vat_base: 'Tax base in CZK',
|
||||
vat_rate: 'Rate',
|
||||
vat_amount: 'VAT in CZK',
|
||||
vat_with_total: 'Total incl. VAT in CZK',
|
||||
received_by: 'Received by:',
|
||||
stamp: 'Stamp:',
|
||||
ico: 'Reg. No.: ',
|
||||
dic: 'Tax ID: ',
|
||||
title: "Invoice",
|
||||
heading: "INVOICE - TAX DOCUMENT No.",
|
||||
supplier: "Supplier",
|
||||
customer: "Customer",
|
||||
bank: "Bank:",
|
||||
swift: "SWIFT:",
|
||||
iban: "IBAN:",
|
||||
account_no: "Account No.:",
|
||||
var_symbol: "Variable symbol:",
|
||||
const_symbol: "Constant symbol:",
|
||||
order_no: "Order No.:",
|
||||
issue_date: "Issue date:",
|
||||
due_date: "Due date:",
|
||||
tax_date: "Tax point date:",
|
||||
payment_method: "Payment method:",
|
||||
billing: "We invoice you for:",
|
||||
col_no: "No.",
|
||||
col_desc: "Description",
|
||||
col_qty: "Quantity",
|
||||
col_unit_price: "Unit price",
|
||||
col_price: "Price",
|
||||
col_vat_pct: "VAT%",
|
||||
col_vat: "VAT",
|
||||
col_total: "Total",
|
||||
subtotal: "Subtotal:",
|
||||
vat_label: "VAT",
|
||||
total: "Total to pay",
|
||||
amounts_in: "Amounts are in",
|
||||
notes: "Notes",
|
||||
issued_by: "Issued by:",
|
||||
notice:
|
||||
"Please note that in case of late payment, we will charge default interest" +
|
||||
" at the agreed or statutory rate and a contractual penalty (if agreed).",
|
||||
vat_recap: "VAT recapitulation in CZK:",
|
||||
vat_base: "Tax base in CZK",
|
||||
vat_rate: "Rate",
|
||||
vat_amount: "VAT in CZK",
|
||||
vat_with_total: "Total incl. VAT in CZK",
|
||||
received_by: "Received by:",
|
||||
stamp: "Stamp:",
|
||||
ico: "Reg. No.: ",
|
||||
dic: "Tax ID: ",
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Route ───────────────────────────────────────────────────────── */
|
||||
|
||||
export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.export') }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const query = request.query as Record<string, string>;
|
||||
const lang = query.lang === 'en' ? 'en' : 'cs';
|
||||
const t = translations[lang];
|
||||
export default async function invoicesPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.export") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const query = request.query as Record<string, string>;
|
||||
const lang = query.lang === "en" ? "en" : "cs";
|
||||
const t = translations[lang];
|
||||
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
return reply.status(404).type('text/html').send('<html><body><h1>Faktura nenalezena</h1></body></html>');
|
||||
}
|
||||
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
}) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst() as Record<string, unknown> | null;
|
||||
|
||||
// Order number lookup
|
||||
let orderNumber = '';
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: { order_number: true, customer_order_number: true, created_at: true },
|
||||
const invoice = await prisma.invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(String(orderRow.customer_order_number || orderRow.order_number || ''));
|
||||
|
||||
if (!invoice) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
|
||||
}
|
||||
}
|
||||
|
||||
// Logo
|
||||
let logoImg = '';
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = 'image/png';
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp';
|
||||
const b64 = buf.toString('base64');
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
const items = await prisma.invoice_items.findMany({
|
||||
where: { invoice_id: id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
const currency = invoice.currency || 'CZK';
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat += lineSubtotal * rate / 100;
|
||||
let customer: Record<string, unknown> | null = null;
|
||||
if (invoice.customer_id) {
|
||||
customer = (await prisma.customers.findUnique({
|
||||
where: { id: invoice.customer_id },
|
||||
})) as Record<string, unknown> | null;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = '';
|
||||
try {
|
||||
const spaydParts = [
|
||||
'SPD*1.0',
|
||||
'ACC:' + (invoice.bank_iban || '').replace(/ /g, ''),
|
||||
'AM:' + totalToPay.toFixed(2),
|
||||
'CC:' + currency,
|
||||
'X-VS:' + (invoice.invoice_number || ''),
|
||||
'X-KS:' + (invoice.constant_symbol || '0308'),
|
||||
'MSG:' + t.title + ' ' + (invoice.invoice_number || ''),
|
||||
];
|
||||
const spaydString = spaydParts.join('*');
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: 'svg',
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
// Order number lookup
|
||||
let orderNumber = "";
|
||||
if (invoice.order_id) {
|
||||
const orderRow = await prisma.orders.findUnique({
|
||||
where: { id: invoice.order_id },
|
||||
select: {
|
||||
order_number: true,
|
||||
customer_order_number: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
if (orderRow) {
|
||||
orderNumber = escapeHtml(
|
||||
String(
|
||||
orderRow.customer_order_number || orderRow.order_number || "",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VAT recapitulation (always in CZK)
|
||||
const isForeign = currency.toUpperCase() !== 'CZK';
|
||||
const cnbRate = 1.0; // Skip CNB rate conversion
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{ rate: number; base: number; vat: number; total: number }> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
// Logo
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data as Buffer);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
const b64 = buf.toString("base64");
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Address lines
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
const currency = invoice.currency || "CZK";
|
||||
const applyVat = !!invoice.apply_vat;
|
||||
|
||||
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
// Calculations
|
||||
const vatSummary: Record<string, { base: number; vat: number }> = {};
|
||||
let subtotal = 0;
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = '';
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === 'email' && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
for (const item of items) {
|
||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
||||
subtotal += lineSubtotal;
|
||||
const rate = Number(item.vat_rate);
|
||||
const key = String(rate);
|
||||
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
|
||||
vatSummary[key].base += lineSubtotal;
|
||||
if (applyVat) {
|
||||
vatSummary[key].vat += (lineSubtotal * rate) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
let totalVat = 0;
|
||||
for (const data of Object.values(vatSummary)) {
|
||||
totalVat += data.vat;
|
||||
}
|
||||
const totalToPay = subtotal + totalVat;
|
||||
|
||||
// QR code - SPAYD payment format
|
||||
let qrSvg = "";
|
||||
try {
|
||||
const spaydParts = [
|
||||
"SPD*1.0",
|
||||
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
|
||||
"AM:" + totalToPay.toFixed(2),
|
||||
"CC:" + currency,
|
||||
"X-VS:" + (invoice.invoice_number || ""),
|
||||
"X-KS:" + (invoice.constant_symbol || "0308"),
|
||||
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
|
||||
];
|
||||
const spaydString = spaydParts.join("*");
|
||||
qrSvg = await QRCode.toString(spaydString, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 200,
|
||||
});
|
||||
} catch {
|
||||
// QR generation failed — leave empty
|
||||
}
|
||||
|
||||
// VAT recapitulation (always in CZK)
|
||||
const isForeign = currency.toUpperCase() !== "CZK";
|
||||
const cnbRate = 1.0; // Skip CNB rate conversion
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
base: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Address lines
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Supplier email/web from custom_fields
|
||||
let suppEmail = "";
|
||||
if (settings?.custom_fields) {
|
||||
const raw = settings.custom_fields;
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const fields = (parsed as Record<string, unknown>).fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const f of fields) {
|
||||
if (f.name && f.name.toLowerCase() === "email" && f.value) {
|
||||
suppEmail = String(f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
const invoiceNumber = escapeHtml(invoice.invoice_number);
|
||||
|
||||
// Items HTML
|
||||
const itemsHtml = items.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? lineSubtotal * vatRate / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
// Items HTML
|
||||
const itemsHtml = items
|
||||
.map((item, i) => {
|
||||
const qty = Number(item.quantity);
|
||||
const unitPrice = Number(item.unit_price);
|
||||
const lineSubtotal = qty * unitPrice;
|
||||
const vatRate = Number(item.vat_rate);
|
||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||
const lineTotal = lineSubtotal + lineVat;
|
||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||
|
||||
return `<tr>
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}</td>
|
||||
@@ -376,53 +431,58 @@ export default async function invoicesPdfRoutes(fastify: FastifyInstance): Promi
|
||||
<td class="right">${formatNum(lineVat)}</td>
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
// VAT recap rows
|
||||
const vatRecapHtml = vatRecap.map(vr => `<tr>
|
||||
// VAT recap rows
|
||||
const vatRecapHtml = vatRecap
|
||||
.map(
|
||||
(vr) => `<tr>
|
||||
<td class="right">${formatNum(vr.base)}</td>
|
||||
<td class="center">${Math.floor(vr.rate)}%</td>
|
||||
<td class="right">${formatNum(vr.vat)}</td>
|
||||
<td class="right">${formatNum(vr.total)}</td>
|
||||
</tr>`).join('');
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// VAT detail rows for totals section
|
||||
let vatDetailHtml = '';
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
// VAT detail rows for totals section
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
if (data.vat > 0) {
|
||||
vatDetailHtml += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? '';
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, '').trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
// Notes section
|
||||
const notesRaw = invoice.notes ?? "";
|
||||
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
|
||||
const notesHtml = notesStripped
|
||||
? `
|
||||
<!-- Poznamky -->
|
||||
<div class="invoice-notes">
|
||||
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
|
||||
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
: "";
|
||||
|
||||
// Quill indent CSS
|
||||
let indentCSS = '';
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Quill indent CSS
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -833,7 +893,7 @@ ${indentCSS}
|
||||
<!-- Hlavicka -->
|
||||
<div class="invoice-header">
|
||||
<div class="left">
|
||||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ''}
|
||||
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
|
||||
</div>
|
||||
<div class="invoice-title">${escapeHtml(t.heading)} ${invoiceNumber}</div>
|
||||
</div>
|
||||
@@ -866,7 +926,7 @@ ${indentCSS}
|
||||
<div class="vs-block">
|
||||
${escapeHtml(t.var_symbol)} <strong>${invoiceNumber}</strong>
|
||||
${escapeHtml(t.const_symbol)} <strong>${escapeHtml(invoice.constant_symbol)}</strong><br>
|
||||
${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ''}
|
||||
${orderNumber ? `${escapeHtml(t.order_no)} ${orderNumber}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
@@ -923,8 +983,8 @@ ${indentCSS}
|
||||
|
||||
<!-- Vystavil -->
|
||||
<div class="issued-by">
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || '')}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ''}
|
||||
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
|
||||
${suppEmail ? `<br> ${escapeHtml(suppEmail)}` : ""}
|
||||
</div>
|
||||
|
||||
<!-- Upozorneni -->
|
||||
@@ -967,6 +1027,7 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return reply.type('text/html').send(html);
|
||||
});
|
||||
return reply.type("text/html").send(html);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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 { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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 {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../../schemas/invoices.schema";
|
||||
import {
|
||||
markOverdueInvoices,
|
||||
listInvoices,
|
||||
@@ -15,108 +18,181 @@ import {
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
deleteInvoice,
|
||||
} from '../../services/invoices.service';
|
||||
|
||||
export default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
} from "../../services/invoices.service";
|
||||
|
||||
export default async function invoicesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
|
||||
fastify.addHook('onRequest', async (request) => {
|
||||
if (request.method !== 'GET') return;
|
||||
fastify.addHook("onRequest", async (request) => {
|
||||
if (request.method !== "GET") return;
|
||||
await markOverdueInvoices();
|
||||
});
|
||||
|
||||
// GET /api/admin/invoices
|
||||
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order, search } = parsePagination(query);
|
||||
|
||||
const result = await listInvoices({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort: String(query.sort || ''),
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
const result = await listInvoices({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort: String(query.sort || ""),
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, page, limit) });
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: buildPaginationMeta(result.total, page, limit),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/next-number
|
||||
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => {
|
||||
const result = await getNextInvoiceNumberFormatted();
|
||||
return success(reply, result);
|
||||
});
|
||||
fastify.get(
|
||||
"/next-number",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (_request, reply) => {
|
||||
const result = await getNextInvoiceNumberFormatted();
|
||||
return success(reply, result);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/stats
|
||||
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const month = query.month ? Number(query.month) : undefined;
|
||||
const year = query.year ? Number(query.year) : undefined;
|
||||
const stats = await getInvoiceStats(month, year);
|
||||
return success(reply, stats);
|
||||
});
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const month = query.month ? Number(query.month) : undefined;
|
||||
const year = query.year ? Number(query.year) : undefined;
|
||||
const stats = await getInvoiceStats(month, year);
|
||||
return success(reply, stats);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/order-data/:id
|
||||
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
||||
const orderId = parseId(request.params.id, reply);
|
||||
if (orderId === null) return;
|
||||
const result = await getOrderDataForInvoice(orderId);
|
||||
if (!result) return error(reply, 'Objednávka nenalezena', 404);
|
||||
return success(reply, result);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/order-data/:id",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (request, reply) => {
|
||||
const orderId = parseId(request.params.id, reply);
|
||||
if (orderId === null) return;
|
||||
const result = await getOrderDataForInvoice(orderId);
|
||||
if (!result) return error(reply, "Objednávka nenalezena", 404);
|
||||
return success(reply, result);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/invoices/:id
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await getInvoice(id);
|
||||
if (!invoice) return error(reply, 'Faktura nenalezena', 404);
|
||||
return success(reply, invoice);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await getInvoice(id);
|
||||
if (!invoice) return error(reply, "Faktura nenalezena", 404);
|
||||
return success(reply, invoice);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/invoices
|
||||
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateInvoiceSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateInvoiceSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const invoice = await createInvoice(body);
|
||||
const invoice = await createInvoice(body);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` });
|
||||
// Return both invoice_id and id for frontend compatibility
|
||||
return success(reply, { id: invoice.id, invoice_id: invoice.id, invoice_number: invoice.invoice_number }, 201, 'Faktura byla vystavena');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "invoice",
|
||||
entityId: invoice.id,
|
||||
description: `Vytvořena faktura ${invoice.invoice_number}`,
|
||||
});
|
||||
// Return both invoice_id and id for frontend compatibility
|
||||
return success(
|
||||
reply,
|
||||
{
|
||||
id: invoice.id,
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoice_number,
|
||||
},
|
||||
201,
|
||||
"Faktura byla vystavena",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/admin/invoices/:id
|
||||
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 parsed = parseBody(UpdateInvoiceSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
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 parsed = parseBody(UpdateInvoiceSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await updateInvoice(id, body);
|
||||
const result = await updateInvoice(id, body);
|
||||
|
||||
if ('error' in result) {
|
||||
if (result.error === 'not_found') return error(reply, 'Faktura nenalezena', 404);
|
||||
if (result.error === 'invalid_transition') return error(reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400);
|
||||
}
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Faktura nenalezena", 404);
|
||||
if (result.error === "invalid_transition")
|
||||
return error(
|
||||
reply,
|
||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${(result as any).invoice_number}` });
|
||||
return success(reply, { id }, 200, 'Faktura byla aktualizována');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
description: `Upravena faktura ${(result as any).invoice_number}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Faktura byla aktualizována");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/invoices/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await deleteInvoice(id);
|
||||
if (!existing) return error(reply, 'Faktura nenalezena', 404);
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await deleteInvoice(id);
|
||||
if (!existing) return error(reply, "Faktura nenalezena", 404);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` });
|
||||
return success(reply, null, 200, 'Faktura smazána');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
description: `Smazána faktura ${existing.invoice_number}`,
|
||||
});
|
||||
return success(reply, null, 200, "Faktura smazána");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
|
||||
function formatDate(date: Date | string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** Format number with comma decimal separator and non-breaking space thousands separator */
|
||||
function formatNum(n: number, decimals: number): string {
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
|
||||
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||
return n < 0 ? `-${result}` : result;
|
||||
}
|
||||
@@ -22,66 +22,92 @@ function formatNum(n: number, decimals: number): string {
|
||||
function formatCurrency(amount: number, currency: string): string {
|
||||
const n = Number(amount) || 0;
|
||||
switch (currency) {
|
||||
case 'EUR': return `${formatNum(n, 2)} \u20AC`;
|
||||
case 'USD': return `$${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
|
||||
case 'CZK': return `${formatNum(n, 2)} K\u010D`;
|
||||
case 'GBP': return `\u00A3${Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
|
||||
default: return `${formatNum(n, 2)} ${currency}`;
|
||||
case "EUR":
|
||||
return `${formatNum(n, 2)} \u20AC`;
|
||||
case "USD":
|
||||
return `$${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
case "CZK":
|
||||
return `${formatNum(n, 2)} K\u010D`;
|
||||
case "GBP":
|
||||
return `\u00A3${Math.abs(n)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
||||
default:
|
||||
return `${formatNum(n, 2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
|
||||
function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return '';
|
||||
const allowedTags = '<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
|
||||
if (!html) return "";
|
||||
const allowedTags =
|
||||
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
|
||||
// Simple strip_tags equivalent: remove tags not in allowed list
|
||||
let s = html;
|
||||
// Remove dangerous tags with content
|
||||
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, '');
|
||||
s = s.replace(/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, '');
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
);
|
||||
s = s.replace(
|
||||
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||
"",
|
||||
);
|
||||
// Strip event handlers
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '');
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
// Strip javascript: in href
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, ' ');
|
||||
s = s.replace(/( )/g, " ");
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = '';
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
prev = s;
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, '<span$1>$2');
|
||||
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface AddressResult { name: string; lines: string[] }
|
||||
interface AddressResult {
|
||||
name: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
t: (key: string) => string,
|
||||
): AddressResult {
|
||||
if (!entity) return { name: '', lines: [] };
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
const nameKey = isSupplier ? 'company_name' : 'name';
|
||||
const name = String(entity[nameKey] || '');
|
||||
const nameKey = isSupplier ? "company_name" : "name";
|
||||
const name = String(entity[nameKey] || "");
|
||||
|
||||
// Parse custom_fields
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = [];
|
||||
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
|
||||
[];
|
||||
let fieldOrder: string[] | null = null;
|
||||
const raw = entity.custom_fields;
|
||||
if (raw) {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ((parsed as Record<string, unknown>).fields) {
|
||||
cfData = ((parsed as Record<string, unknown>).fields as typeof cfData) || [];
|
||||
fieldOrder = ((parsed as Record<string, unknown>).field_order || (parsed as Record<string, unknown>).fieldOrder) as string[] | null;
|
||||
cfData =
|
||||
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
|
||||
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
|
||||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
|
||||
} else if (Array.isArray(parsed)) {
|
||||
cfData = parsed;
|
||||
}
|
||||
@@ -91,29 +117,37 @@ function buildAddressLines(
|
||||
// Legacy PascalCase key compat
|
||||
if (Array.isArray(fieldOrder)) {
|
||||
const legacyMap: Record<string, string> = {
|
||||
Name: 'name', CompanyName: 'company_name',
|
||||
Street: 'street', CityPostal: 'city_postal',
|
||||
Country: 'country', CompanyId: 'company_id', VatId: 'vat_id',
|
||||
Name: "name",
|
||||
CompanyName: "company_name",
|
||||
Street: "street",
|
||||
CityPostal: "city_postal",
|
||||
Country: "country",
|
||||
CompanyId: "company_id",
|
||||
VatId: "vat_id",
|
||||
};
|
||||
fieldOrder = fieldOrder.map(k => legacyMap[k] || k);
|
||||
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
|
||||
}
|
||||
|
||||
const fieldMap: Record<string, string> = {};
|
||||
if (name) fieldMap[nameKey] = name;
|
||||
if (entity.street) fieldMap.street = String(entity.street);
|
||||
const cityParts = [entity.city || '', entity.postal_code || ''].filter(Boolean).map(String);
|
||||
const cityPostal = cityParts.join(' ').trim();
|
||||
const cityParts = [entity.city || "", entity.postal_code || ""]
|
||||
.filter(Boolean)
|
||||
.map(String);
|
||||
const cityPostal = cityParts.join(" ").trim();
|
||||
if (cityPostal) fieldMap.city_postal = cityPostal;
|
||||
if (entity.country) fieldMap.country = String(entity.country);
|
||||
if (entity.company_id) fieldMap.company_id = `${t('ico')}: ${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${t('dic')}: ${entity.vat_id}`;
|
||||
if (entity.company_id)
|
||||
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
|
||||
|
||||
cfData.forEach((cf, i) => {
|
||||
const cfName = (cf.name || '').trim();
|
||||
const cfValue = (cf.value || '').trim();
|
||||
const cfName = (cf.name || "").trim();
|
||||
const cfValue = (cf.value || "").trim();
|
||||
const showLabel = cf.showLabel !== false;
|
||||
if (cfValue) {
|
||||
fieldMap[`custom_${i}`] = (showLabel && cfName) ? `${cfName}: ${cfValue}` : cfValue;
|
||||
fieldMap[`custom_${i}`] =
|
||||
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -138,172 +172,199 @@ function buildAddressLines(
|
||||
}
|
||||
|
||||
const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
title: { EN: 'PRICE QUOTATION', CZ: 'CENOV\u00C1 NAB\u00CDDKA' },
|
||||
scope_title: { EN: 'SCOPE OF THE PROJECT', CZ: 'ROZSAH PROJEKTU' },
|
||||
valid_until: { EN: 'Valid until', CZ: 'Platnost do' },
|
||||
customer: { EN: 'Customer', CZ: 'Z\u00E1kazn\u00EDk' },
|
||||
supplier: { EN: 'Supplier', CZ: 'Dodavatel' },
|
||||
no: { EN: 'N.', CZ: '\u010C.' },
|
||||
description: { EN: 'Description', CZ: 'Popis' },
|
||||
qty: { EN: 'Qty', CZ: 'Mn.' },
|
||||
unit_price: { EN: 'Unit Price', CZ: 'Jedn. cena' },
|
||||
included: { EN: 'Included', CZ: 'Zahrnuto' },
|
||||
total: { EN: 'Total', CZ: 'Celkem' },
|
||||
subtotal: { EN: 'Subtotal', CZ: 'Mezisou\u010Det' },
|
||||
vat: { EN: 'VAT', CZ: 'DPH' },
|
||||
total_to_pay: { EN: 'Total to pay', CZ: 'Celkem k \u00FAhrad\u011B' },
|
||||
exchange_rate: { EN: 'Exchange rate', CZ: 'Sm\u011Bnn\u00FD kurz' },
|
||||
ico: { EN: 'ID', CZ: 'I\u010CO' },
|
||||
dic: { EN: 'VAT ID', CZ: 'DI\u010C' },
|
||||
page: { EN: 'Page', CZ: 'Strana' },
|
||||
of: { EN: 'of', CZ: 'z' },
|
||||
title: { EN: "PRICE QUOTATION", CZ: "CENOV\u00C1 NAB\u00CDDKA" },
|
||||
scope_title: { EN: "SCOPE OF THE PROJECT", CZ: "ROZSAH PROJEKTU" },
|
||||
valid_until: { EN: "Valid until", CZ: "Platnost do" },
|
||||
customer: { EN: "Customer", CZ: "Z\u00E1kazn\u00EDk" },
|
||||
supplier: { EN: "Supplier", CZ: "Dodavatel" },
|
||||
no: { EN: "N.", CZ: "\u010C." },
|
||||
description: { EN: "Description", CZ: "Popis" },
|
||||
qty: { EN: "Qty", CZ: "Mn." },
|
||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||
total: { EN: "Total", CZ: "Celkem" },
|
||||
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
|
||||
vat: { EN: "VAT", CZ: "DPH" },
|
||||
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
|
||||
exchange_rate: { EN: "Exchange rate", CZ: "Sm\u011Bnn\u00FD kurz" },
|
||||
ico: { EN: "ID", CZ: "I\u010CO" },
|
||||
dic: { EN: "VAT ID", CZ: "DI\u010C" },
|
||||
page: { EN: "Page", CZ: "Strana" },
|
||||
of: { EN: "of", CZ: "z" },
|
||||
};
|
||||
|
||||
export default async function offersPdfRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
export default async function offersPdfRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: 'asc' } },
|
||||
scope_sections: { orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!quotation) {
|
||||
return reply.status(404).type('text/html').send('<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>');
|
||||
}
|
||||
if (!quotation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.type("text/html")
|
||||
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>");
|
||||
}
|
||||
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const isCzech = (quotation.language ?? 'EN') !== 'EN';
|
||||
const langKey = isCzech ? 'CZ' : 'EN';
|
||||
const currency = quotation.currency || 'EUR';
|
||||
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const isCzech = (quotation.language ?? "EN") !== "EN";
|
||||
const langKey = isCzech ? "CZ" : "EN";
|
||||
const currency = quotation.currency || "EUR";
|
||||
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
|
||||
|
||||
// Logo
|
||||
let logoImg = '';
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data);
|
||||
let mime = 'image/png';
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) mime = 'image/jpeg';
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = 'image/gif';
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = 'image/webp';
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString('base64')}" class="logo" />`;
|
||||
}
|
||||
// Logo
|
||||
let logoImg = "";
|
||||
if (settings?.logo_data) {
|
||||
const buf = Buffer.from(settings.logo_data);
|
||||
let mime = "image/png";
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
|
||||
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
|
||||
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
|
||||
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
|
||||
}
|
||||
|
||||
// Calculations
|
||||
const items = quotation.quotation_items;
|
||||
let subtotal = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
subtotal += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
const applyVat = !!quotation.apply_vat;
|
||||
const vatRate = Number(quotation.vat_rate) || 21;
|
||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||
const totalToPay = subtotal + vatAmount;
|
||||
const exchangeRate = Number(quotation.exchange_rate) || 0;
|
||||
// Calculations
|
||||
const items = quotation.quotation_items;
|
||||
let subtotal = 0;
|
||||
for (const item of items) {
|
||||
if (item.is_included_in_total !== false) {
|
||||
subtotal +=
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
}
|
||||
const applyVat = !!quotation.apply_vat;
|
||||
const vatRate = Number(quotation.vat_rate) || 21;
|
||||
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
|
||||
const totalToPay = subtotal + vatAmount;
|
||||
const exchangeRate = Number(quotation.exchange_rate) || 0;
|
||||
|
||||
// Scope content check
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || '').trim() || (s.title || '').trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Scope content check
|
||||
let hasScopeContent = false;
|
||||
for (const s of quotation.scope_sections) {
|
||||
if ((s.content || "").trim() || (s.title || "").trim()) {
|
||||
hasScopeContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Addresses
|
||||
const cust = buildAddressLines(quotation.customers as unknown as Record<string, unknown>, false, t);
|
||||
const supp = buildAddressLines(settings as unknown as Record<string, unknown>, true, t);
|
||||
// Addresses
|
||||
const cust = buildAddressLines(
|
||||
quotation.customers as unknown as Record<string, unknown>,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
const supp = buildAddressLines(
|
||||
settings as unknown as Record<string, unknown>,
|
||||
true,
|
||||
t,
|
||||
);
|
||||
|
||||
const custLinesHtml = cust.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
const suppLinesHtml = supp.lines.map(l => `<div class="address-line">${escapeHtml(l)}</div>`).join('');
|
||||
const custLinesHtml = cust.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
const suppLinesHtml = supp.lines
|
||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.join("");
|
||||
|
||||
// Indentation CSS for Quill
|
||||
let indentCSS = '';
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
// Indentation CSS for Quill
|
||||
let indentCSS = "";
|
||||
for (let n = 1; n <= 9; n++) {
|
||||
const pad = n * 3;
|
||||
const liPad = n * 3 + 1.5;
|
||||
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
|
||||
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
|
||||
}
|
||||
|
||||
// Items HTML
|
||||
let itemsHtml = '';
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const subDesc = item.item_description || '';
|
||||
const evenClass = (i % 2 === 1) ? ' class="even"' : '';
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
// Items HTML
|
||||
let itemsHtml = "";
|
||||
items.forEach((item, i) => {
|
||||
const lineTotal =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const subDesc = item.item_description || "";
|
||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||
itemsHtml += `<tr${evenClass}>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ''}</td>
|
||||
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || '').trim() ? ` / ${escapeHtml((item.unit || '').trim())}` : ''}</td>
|
||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Totals HTML
|
||||
let totalsHtml = '';
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
// Totals HTML
|
||||
let totalsHtml = "";
|
||||
if (applyVat) {
|
||||
totalsHtml += `<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t('subtotal'))}:</span>
|
||||
<span class="label">${escapeHtml(t("subtotal"))}:</span>
|
||||
<span class="value">${formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t('vat'))} (${Math.round(vatRate)}%):</span>
|
||||
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
|
||||
<span class="value">${formatCurrency(vatAmount, currency)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t('total_to_pay'))}</span>
|
||||
}
|
||||
totalsHtml += `<div class="grand">
|
||||
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
|
||||
<span class="value">${formatCurrency(totalToPay, currency)}</span>
|
||||
</div>`;
|
||||
if (exchangeRate > 0) {
|
||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t('exchange_rate'))}: ${formatNum(exchangeRate, 4)}</div>`;
|
||||
}
|
||||
if (exchangeRate > 0) {
|
||||
totalsHtml += `<div class="exchange-rate">${escapeHtml(t("exchange_rate"))}: ${formatNum(exchangeRate, 4)}</div>`;
|
||||
}
|
||||
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
const quotationNumber = escapeHtml(quotation.quotation_number);
|
||||
|
||||
// Scope HTML
|
||||
let scopeHtml = '';
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
// Scope HTML
|
||||
let scopeHtml = "";
|
||||
if (hasScopeContent) {
|
||||
scopeHtml += '<div class="scope-page">';
|
||||
scopeHtml += `<div class="page-header">
|
||||
<div class="left">
|
||||
<div class="page-title">${escapeHtml(t('title'))}</div>
|
||||
<div class="page-title">${escapeHtml(t("title"))}</div>
|
||||
<div class="quotation-number">${quotationNumber}</div>
|
||||
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ''}
|
||||
<div class="valid-until">${escapeHtml(t('valid_until'))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
|
||||
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
|
||||
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
|
||||
</div>
|
||||
${logoImg ? `<div class="right">${logoImg}</div>` : ''}
|
||||
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
|
||||
</div>
|
||||
<hr class="separator" />`;
|
||||
|
||||
for (const section of quotation.scope_sections) {
|
||||
const title = isCzech && (section.title_cz || '').trim() ? section.title_cz : (section.title || '');
|
||||
const content = (section.content || '').trim();
|
||||
if (!title && !content) continue;
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title) scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content) scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
|
||||
scopeHtml += '</div>';
|
||||
}
|
||||
scopeHtml += '</div>';
|
||||
}
|
||||
for (const section of quotation.scope_sections) {
|
||||
const title =
|
||||
isCzech && (section.title_cz || "").trim()
|
||||
? section.title_cz
|
||||
: section.title || "";
|
||||
const content = (section.content || "").trim();
|
||||
if (!title && !content) continue;
|
||||
scopeHtml += '<div class="scope-section">';
|
||||
if (title)
|
||||
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
|
||||
if (content)
|
||||
scopeHtml += `<div class="section-content">${cleanQuillHtml(content)}</div>`;
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
scopeHtml += "</div>";
|
||||
}
|
||||
|
||||
const pageLabel = escapeHtml(t('page'));
|
||||
const ofLabel = escapeHtml(t('of'));
|
||||
const pageLabel = escapeHtml(t("page"));
|
||||
const ofLabel = escapeHtml(t("of"));
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? 'cs' : 'en'}">
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${isCzech ? "cs" : "en"}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${quotationNumber}</title>
|
||||
@@ -655,22 +716,22 @@ ${indentCSS}
|
||||
<div class="first-content">
|
||||
<div class="page-header">
|
||||
<div class="left">
|
||||
<div class="page-title">${escapeHtml(t('title'))}</div>
|
||||
<div class="page-title">${escapeHtml(t("title"))}</div>
|
||||
<div class="quotation-number">${quotationNumber}</div>
|
||||
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ''}
|
||||
<div class="valid-until">${escapeHtml(t('valid_until'))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
|
||||
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
|
||||
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="separator" />
|
||||
|
||||
<div class="addresses">
|
||||
<div class="address-block left">
|
||||
<div class="address-label">${escapeHtml(t('customer'))}</div>
|
||||
<div class="address-label">${escapeHtml(t("customer"))}</div>
|
||||
<div class="address-name">${escapeHtml(cust.name)}</div>
|
||||
${custLinesHtml}
|
||||
</div>
|
||||
<div class="address-block right">
|
||||
<div class="address-label">${escapeHtml(t('supplier'))}</div>
|
||||
<div class="address-label">${escapeHtml(t("supplier"))}</div>
|
||||
<div class="address-name">${escapeHtml(supp.name)}</div>
|
||||
${suppLinesHtml}
|
||||
</div>
|
||||
@@ -679,11 +740,11 @@ ${indentCSS}
|
||||
<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:5%">${escapeHtml(t('no'))}</th>
|
||||
<th style="width:44%">${escapeHtml(t('description'))}</th>
|
||||
<th class="center" style="width:13%">${escapeHtml(t('qty'))}</th>
|
||||
<th class="right" style="width:18%">${escapeHtml(t('unit_price'))}</th>
|
||||
<th class="right" style="width:20%">${escapeHtml(t('total'))}</th>
|
||||
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
||||
<th style="width:44%">${escapeHtml(t("description"))}</th>
|
||||
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
|
||||
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
|
||||
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -707,11 +768,16 @@ ${indentCSS}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return reply.type('text/html').send(html);
|
||||
|
||||
} catch (err) {
|
||||
request.log.error(err, 'PDF generation failed');
|
||||
return reply.status(500).type('text/html').send('<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>');
|
||||
}
|
||||
});
|
||||
return reply.type("text/html").send(html);
|
||||
} catch (err) {
|
||||
request.log.error(err, "PDF generation failed");
|
||||
return reply
|
||||
.status(500)
|
||||
.type("text/html")
|
||||
.send(
|
||||
"<html><body><h1>Chyba p\u0159i generov\u00E1n\u00ED PDF</h1></body></html>",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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 { FastifyInstance } from "fastify";
|
||||
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 {
|
||||
listOrders,
|
||||
getOrder,
|
||||
@@ -14,140 +18,258 @@ import {
|
||||
updateOrder,
|
||||
deleteOrder,
|
||||
getNextOrderNumber,
|
||||
} from '../../services/orders.service';
|
||||
} from "../../services/orders.service";
|
||||
|
||||
import multipart from '@fastify/multipart';
|
||||
import multipart from "@fastify/multipart";
|
||||
|
||||
export default async function ordersRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function ordersRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
// GET /api/admin/orders/next-number
|
||||
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => {
|
||||
const number = await getNextOrderNumber();
|
||||
return success(reply, { number, next_number: number });
|
||||
});
|
||||
fastify.get(
|
||||
"/next-number",
|
||||
{ preHandler: requirePermission("orders.create") },
|
||||
async (_request, reply) => {
|
||||
const number = await getNextOrderNumber();
|
||||
return success(reply, { number, next_number: number });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get('/', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order } = parsePagination(query);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order } = parsePagination(query);
|
||||
|
||||
const result = await listOrders({
|
||||
page, limit, skip, sort, order,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
const result = await listOrders({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort,
|
||||
order,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, result.page, result.limit) });
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: buildPaginationMeta(
|
||||
result.total,
|
||||
result.page,
|
||||
result.limit,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const order = await getOrder(id);
|
||||
if (!order) return error(reply, 'Objednávka nenalezena', 404);
|
||||
return success(reply, order);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const order = await getOrder(id);
|
||||
if (!order) return error(reply, "Objednávka nenalezena", 404);
|
||||
return success(reply, order);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/orders/:id/attachment
|
||||
fastify.get<{ Params: { id: string } }>('/:id/attachment', { preHandler: requirePermission('orders.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, 'Příloha nenalezena', 404);
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id/attachment",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||
|
||||
return reply
|
||||
.type('application/pdf')
|
||||
.header('Content-Disposition', `inline; filename="${attachment.filename}"`)
|
||||
.send(attachment.data);
|
||||
});
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${attachment.filename}"`,
|
||||
)
|
||||
.send(attachment.data);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/orders — handles both JSON (manual) and multipart (from quotation)
|
||||
fastify.post('/', { preHandler: requirePermission('orders.create') }, async (request, reply) => {
|
||||
const isMultipart = request.headers['content-type']?.includes('multipart');
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("orders.create") },
|
||||
async (request, reply) => {
|
||||
const isMultipart =
|
||||
request.headers["content-type"]?.includes("multipart");
|
||||
|
||||
if (isMultipart) {
|
||||
// === Order from quotation flow (multipart) ===
|
||||
const fields: Record<string, string> = {};
|
||||
let attachmentBuffer: Buffer | null = null;
|
||||
let attachmentName: string | null = null;
|
||||
if (isMultipart) {
|
||||
// === Order from quotation flow (multipart) ===
|
||||
const fields: Record<string, string> = {};
|
||||
let attachmentBuffer: Buffer | null = null;
|
||||
let attachmentName: string | null = null;
|
||||
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'field') {
|
||||
fields[part.fieldname] = String(part.value);
|
||||
} else if (part.type === 'file' && part.fieldname === 'attachment') {
|
||||
attachmentBuffer = await part.toBuffer();
|
||||
attachmentName = part.filename;
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "field") {
|
||||
fields[part.fieldname] = String(part.value);
|
||||
} else if (part.type === "file" && part.fieldname === "attachment") {
|
||||
attachmentBuffer = await part.toBuffer();
|
||||
attachmentName = part.filename;
|
||||
}
|
||||
}
|
||||
|
||||
const quotationId = parseInt(fields.quotationId, 10);
|
||||
const customerOrderNumber = fields.customerOrderNumber || "";
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, "Chybí ID nabídky", 400);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({
|
||||
quotationId,
|
||||
customerOrderNumber,
|
||||
attachmentBuffer,
|
||||
attachmentName,
|
||||
});
|
||||
if ("error" in result)
|
||||
return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "order",
|
||||
entityId: result.data.order_id,
|
||||
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{
|
||||
order_id: result.data.order_id,
|
||||
id: result.data.id,
|
||||
order_number: result.data.order_number,
|
||||
},
|
||||
201,
|
||||
"Objednávka byla vytvořena",
|
||||
);
|
||||
}
|
||||
|
||||
const quotationId = parseInt(fields.quotationId, 10);
|
||||
const customerOrderNumber = fields.customerOrderNumber || '';
|
||||
// === JSON body — either from-quotation (no attachment) or manual order ===
|
||||
const rawBody = request.body as Record<string, unknown>;
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, 'Chybí ID nabídky', 400);
|
||||
// From-quotation flow via JSON (no attachment)
|
||||
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);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({
|
||||
quotationId,
|
||||
customerOrderNumber,
|
||||
});
|
||||
if ("error" in result)
|
||||
return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "order",
|
||||
entityId: result.data.order_id,
|
||||
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{
|
||||
order_id: result.data.order_id,
|
||||
id: result.data.id,
|
||||
order_number: result.data.order_number,
|
||||
},
|
||||
201,
|
||||
"Objednávka byla vytvořena",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber, attachmentBuffer, attachmentName });
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
// Manual order creation
|
||||
const manualParsed = parseBody(CreateOrderSchema, rawBody);
|
||||
if ("error" in manualParsed) return error(reply, manualParsed.error, 400);
|
||||
const body = manualParsed.data;
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.data.order_id, description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}` });
|
||||
return success(reply, { order_id: result.data.order_id, id: result.data.id, order_number: result.data.order_number }, 201, 'Objednávka byla vytvořena');
|
||||
}
|
||||
const result = await createOrder(body as any);
|
||||
|
||||
// === JSON body — either from-quotation (no attachment) or manual order ===
|
||||
const rawBody = request.body as Record<string, unknown>;
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "order",
|
||||
entityId: result.id,
|
||||
description: `Vytvořena objednávka ${result.order_number}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ id: result.id },
|
||||
201,
|
||||
"Objednávka byla vytvořena",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// From-quotation flow via JSON (no attachment)
|
||||
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;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateOrderSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, 'Chybí ID nabídky', 400);
|
||||
}
|
||||
const result = await updateOrder(id, parsed.data as any);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
const result = await createOrderFromQuotation({ quotationId, customerOrderNumber });
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "order",
|
||||
entityId: id,
|
||||
description: `Upravena objednávka ${result.data.order_number}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Objednávka byla uložena");
|
||||
},
|
||||
);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.data.order_id, description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}` });
|
||||
return success(reply, { order_id: result.data.order_id, id: result.data.id, order_number: result.data.order_number }, 201, 'Objednávka byla vytvořena');
|
||||
}
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
// Manual order creation
|
||||
const manualParsed = parseBody(CreateOrderSchema, rawBody);
|
||||
if ('error' in manualParsed) return error(reply, manualParsed.error, 400);
|
||||
const body = manualParsed.data;
|
||||
const result = await deleteOrder(id);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
const result = await createOrder(body as any);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'order', entityId: result.id, description: `Vytvořena objednávka ${result.order_number}` });
|
||||
return success(reply, { id: result.id }, 201, 'Objednávka byla vytvořena');
|
||||
});
|
||||
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateOrderSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const result = await updateOrder(id, parsed.data as any);
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'order', entityId: id, description: `Upravena objednávka ${result.data.order_number}` });
|
||||
return success(reply, { id }, 200, 'Objednávka byla uložena');
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('orders.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const result = await deleteOrder(id);
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'order', entityId: id, description: `Smazána objednávka ${result.data.order_number}` });
|
||||
return success(reply, null, 200, 'Objednávka smazána');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "order",
|
||||
entityId: id,
|
||||
description: `Smazána objednávka ${result.data.order_number}`,
|
||||
});
|
||||
return success(reply, null, 200, "Objednávka smazána");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth } from "../../middleware/auth";
|
||||
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<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
export default async function profileRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
select: {
|
||||
id: true, username: true, email: true, first_name: true, last_name: true,
|
||||
totp_enabled: true, last_login: true, password_changed_at: true,
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
totp_enabled: true,
|
||||
last_login: true,
|
||||
password_changed_at: true,
|
||||
roles: { select: { id: true, name: true, display_name: true } },
|
||||
},
|
||||
});
|
||||
if (!user) return error(reply, 'Uživatel nenalezen', 404);
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
return success(reply, user);
|
||||
});
|
||||
|
||||
fastify.put('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.put("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const parsed = parseBody(UpdateProfileSchema, 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 userId = request.authData!.userId;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.email) {
|
||||
const newEmail = String(body.email).trim();
|
||||
const existing = await prisma.users.findFirst({ where: { email: newEmail, id: { not: userId } } });
|
||||
if (existing) return error(reply, 'E-mail již existuje', 409);
|
||||
const existing = await prisma.users.findFirst({
|
||||
where: { email: newEmail, id: { not: userId } },
|
||||
});
|
||||
if (existing) return error(reply, "E-mail již existuje", 409);
|
||||
data.email = newEmail;
|
||||
}
|
||||
if (body.first_name) data.first_name = String(body.first_name);
|
||||
@@ -40,18 +50,31 @@ export default async function profileRoutes(fastify: FastifyInstance): Promise<v
|
||||
|
||||
if (body.current_password && body.new_password) {
|
||||
const user = await prisma.users.findUnique({ where: { id: userId } });
|
||||
if (!user) return error(reply, 'Uživatel nenalezen', 404);
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
|
||||
const valid = await bcrypt.compare(String(body.current_password), user.password_hash);
|
||||
if (!valid) return error(reply, 'Nesprávné aktuální heslo', 400);
|
||||
const valid = await bcrypt.compare(
|
||||
String(body.current_password),
|
||||
user.password_hash,
|
||||
);
|
||||
if (!valid) return error(reply, "Nesprávné aktuální heslo", 400);
|
||||
|
||||
data.password_hash = await bcrypt.hash(String(body.new_password), config.security.bcryptCost);
|
||||
data.password_hash = await bcrypt.hash(
|
||||
String(body.new_password),
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
data.password_changed_at = new Date();
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'password_change', entityType: 'user', entityId: userId, description: 'Změna hesla' });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "password_change",
|
||||
entityType: "user",
|
||||
entityId: userId,
|
||||
description: "Změna hesla",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: userId }, data });
|
||||
return success(reply, null, 200, 'Profil aktualizován');
|
||||
return success(reply, null, 200, "Profil aktualizován");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import fs from 'fs';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import multipart from '@fastify/multipart';
|
||||
import prisma from '../../config/database';
|
||||
import { config } from '../../config/env';
|
||||
import { requirePermission } from '../../middleware/auth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { success, error } from '../../utils/response';
|
||||
import { NasFileManager } from '../../services/nas-file-manager';
|
||||
import fs from "fs";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import prisma from "../../config/database";
|
||||
import { config } from "../../config/env";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { NasFileManager } from "../../services/nas-file-manager";
|
||||
|
||||
export default async function projectFilesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize } });
|
||||
export default async function projectFilesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
await fastify.register(multipart, {
|
||||
limits: { fileSize: config.nas.maxUploadSize },
|
||||
});
|
||||
|
||||
const fm = new NasFileManager();
|
||||
|
||||
@@ -22,194 +26,230 @@ export default async function projectFilesRoutes(fastify: FastifyInstance): Prom
|
||||
}
|
||||
|
||||
// GET / — list files or download
|
||||
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const subPath = query.path || '';
|
||||
const subPath = query.path || "";
|
||||
|
||||
if (query.action === 'download') {
|
||||
if (!subPath) return error(reply, 'Cesta k souboru je povinná');
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
if (query.action === "download") {
|
||||
if (!subPath) return error(reply, "Cesta k souboru je povinná");
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
const result = fm.downloadFile(project.project_number, subPath);
|
||||
if (!result) return error(reply, 'Soubor nebyl nalezen', 404);
|
||||
const result = fm.downloadFile(project.project_number, subPath);
|
||||
if (!result) return error(reply, "Soubor nebyl nalezen", 404);
|
||||
|
||||
const stream = fs.createReadStream(result.filePath);
|
||||
return reply
|
||||
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`)
|
||||
.header('Content-Type', result.mime)
|
||||
.header('X-Content-Type-Options', 'nosniff')
|
||||
.send(stream);
|
||||
}
|
||||
const stream = fs.createReadStream(result.filePath);
|
||||
return reply
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
|
||||
)
|
||||
.header("Content-Type", result.mime)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.send(stream);
|
||||
}
|
||||
|
||||
// List files
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
// List files
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
const result = fm.listFiles(project.project_number, subPath);
|
||||
if (result === null) {
|
||||
return error(reply, 'Složka nebyla nalezena', 404);
|
||||
}
|
||||
const result = fm.listFiles(project.project_number, subPath);
|
||||
if (result === null) {
|
||||
return error(reply, "Složka nebyla nalezena", 404);
|
||||
}
|
||||
|
||||
return success(reply, {
|
||||
...result,
|
||||
project_number: project.project_number,
|
||||
folder_exists: true,
|
||||
});
|
||||
});
|
||||
return success(reply, {
|
||||
...result,
|
||||
project_number: project.project_number,
|
||||
folder_exists: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST / — create folder (JSON body)
|
||||
fastify.post('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const folderName = String(body.folder_name || '').trim();
|
||||
const path = String(body.path || '');
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const folderName = String(body.folder_name || "").trim();
|
||||
const path = String(body.path || "");
|
||||
|
||||
if (!folderName) return error(reply, 'Název složky je povinný');
|
||||
if ([...folderName].length > 100) return error(reply, 'Název složky je příliš dlouhý (max 100 znaků)');
|
||||
if (!folderName) return error(reply, "Název složky je povinný");
|
||||
if ([...folderName].length > 100)
|
||||
return error(reply, "Název složky je příliš dlouhý (max 100 znaků)");
|
||||
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || '');
|
||||
}
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || "");
|
||||
}
|
||||
|
||||
const err = fm.createFolder(project.project_number, path, folderName);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = fm.createFolder(project.project_number, path, folderName);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'create',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`,
|
||||
newValues: { folder: folderName, path },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Vytvořena složka '${folderName}' v projektu '${project.project_number}'`,
|
||||
newValues: { folder: folderName, path },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Složka byla vytvořena');
|
||||
});
|
||||
return success(reply, null, 200, "Složka byla vytvořena");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /upload — upload file (multipart)
|
||||
fastify.post('/upload', {
|
||||
preHandler: requirePermission('projects.files'),
|
||||
bodyLimit: config.nas.maxUploadSize,
|
||||
}, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.post(
|
||||
"/upload",
|
||||
{
|
||||
preHandler: requirePermission("projects.files"),
|
||||
bodyLimit: config.nas.maxUploadSize,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || '');
|
||||
}
|
||||
// Auto-create project folder if it doesn't exist
|
||||
if (!fm.projectFolderExists(project.project_number)) {
|
||||
fm.createProjectFolder(project.project_number, project.name || "");
|
||||
}
|
||||
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, 'Nebyl nahrán žádný soubor');
|
||||
const file = await request.file();
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
||||
|
||||
const subPath = query.path || '';
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileName = file.filename;
|
||||
const subPath = query.path || "";
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileName = file.filename;
|
||||
|
||||
const err = await fm.uploadFile(project.project_number, subPath, fileBuffer, fileName);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = await fm.uploadFile(
|
||||
project.project_number,
|
||||
subPath,
|
||||
fileBuffer,
|
||||
fileName,
|
||||
);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'create',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Nahrán soubor do projektu '${project.project_number}'`,
|
||||
newValues: { file: fileName, path: subPath },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Nahrán soubor do projektu '${project.project_number}'`,
|
||||
newValues: { file: fileName, path: subPath },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Soubor byl nahrán');
|
||||
});
|
||||
return success(reply, null, 200, "Soubor byl nahrán");
|
||||
},
|
||||
);
|
||||
|
||||
// PUT / — move/rename
|
||||
fastify.put('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.put(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const fromPath = String(body.from_path || '');
|
||||
const toPath = String(body.to_path || '');
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const fromPath = String(body.from_path || "");
|
||||
const toPath = String(body.to_path || "");
|
||||
|
||||
if (!fromPath || !toPath) return error(reply, 'Zdrojová i cílová cesta jsou povinné');
|
||||
if (!fromPath || !toPath)
|
||||
return error(reply, "Zdrojová i cílová cesta jsou povinné");
|
||||
|
||||
const err = fm.moveItem(project.project_number, fromPath, toPath);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = fm.moveItem(project.project_number, fromPath, toPath);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'update',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Přesun/přejmenování v projektu '${project.project_number}'`,
|
||||
oldValues: { path: fromPath },
|
||||
newValues: { path: toPath },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Přesun/přejmenování v projektu '${project.project_number}'`,
|
||||
oldValues: { path: fromPath },
|
||||
newValues: { path: toPath },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Soubor byl přesunut');
|
||||
});
|
||||
return success(reply, null, 200, "Soubor byl přesunut");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE / — delete file/folder
|
||||
fastify.delete('/', { preHandler: requirePermission('projects.files') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, 'Projekt nebyl nalezen', 404);
|
||||
if (!project.project_number) return error(reply, 'Projekt nemá číslo projektu');
|
||||
fastify.delete(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.files") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, string>;
|
||||
const projectId = Number(query.project_id);
|
||||
const project = await getProjectForFiles(projectId);
|
||||
if (!project) return error(reply, "Projekt nebyl nalezen", 404);
|
||||
if (!project.project_number)
|
||||
return error(reply, "Projekt nemá číslo projektu");
|
||||
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, 'Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
if (!fm.isConfigured()) {
|
||||
return error(reply, "Souborový systém není nakonfigurován", 500);
|
||||
}
|
||||
|
||||
const filePath = query.path || '';
|
||||
if (!filePath) return error(reply, 'Cesta k souboru je povinná');
|
||||
const filePath = query.path || "";
|
||||
if (!filePath) return error(reply, "Cesta k souboru je povinná");
|
||||
|
||||
const err = await fm.deleteItem(project.project_number, filePath);
|
||||
if (err !== null) return error(reply, err);
|
||||
const err = await fm.deleteItem(project.project_number, filePath);
|
||||
if (err !== null) return error(reply, err);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'delete',
|
||||
entityType: 'project_file',
|
||||
entityId: project.id,
|
||||
description: `Smazán soubor/složka v projektu '${project.project_number}'`,
|
||||
oldValues: { path: filePath },
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "project_file",
|
||||
entityId: project.id,
|
||||
description: `Smazán soubor/složka v projektu '${project.project_number}'`,
|
||||
oldValues: { path: filePath },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Soubor byl smazán');
|
||||
});
|
||||
return success(reply, null, 200, "Soubor byl smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,198 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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 {
|
||||
listProjects, getProject, createProject, updateProject, deleteProject,
|
||||
createProjectNote, deleteProjectNote, getNextProjectNumber,
|
||||
} from '../../services/projects.service';
|
||||
CreateProjectSchema,
|
||||
UpdateProjectSchema,
|
||||
CreateProjectNoteSchema,
|
||||
} from "../../schemas/projects.schema";
|
||||
import {
|
||||
listProjects,
|
||||
getProject,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
createProjectNote,
|
||||
deleteProjectNote,
|
||||
getNextProjectNumber,
|
||||
} from "../../services/projects.service";
|
||||
|
||||
export default async function projectsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||
export default async function projectsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||
|
||||
const result = await listProjects({
|
||||
page, limit, skip, sort, order, search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
const result = await listProjects({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort,
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, page, limit) });
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: buildPaginationMeta(result.total, page, limit),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const project = await getProject(id);
|
||||
if (!project) return error(reply, 'Projekt nenalezen', 404);
|
||||
return success(reply, project);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const project = await getProject(id);
|
||||
if (!project) return error(reply, "Projekt nenalezen", 404);
|
||||
return success(reply, project);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('projects.create') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateProjectSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("projects.create") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateProjectSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const project = await createProject(parsed.data);
|
||||
const project = await createProject(parsed.data);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'project', entityId: project.id, description: `Vytvořen projekt ${project.name}` });
|
||||
return success(reply, { id: project.id }, 201, 'Projekt byl vytvořen');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
description: `Vytvořen projekt ${project.name}`,
|
||||
});
|
||||
return success(reply, { id: project.id }, 201, "Projekt byl vytvořen");
|
||||
},
|
||||
);
|
||||
|
||||
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 parsed = parseBody(UpdateProjectSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
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 parsed = parseBody(UpdateProjectSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const existing = await updateProject(id, parsed.data);
|
||||
if (!existing) return error(reply, 'Projekt nenalezen', 404);
|
||||
const existing = await updateProject(id, parsed.data);
|
||||
if (!existing) return error(reply, "Projekt nenalezen", 404);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'project', entityId: id, description: `Upraven projekt ${existing.name}` });
|
||||
return success(reply, { id }, 200, 'Projekt byl uložen');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
description: `Upraven projekt ${existing.name}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Projekt byl uložen");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/projects/:id/notes
|
||||
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 parsed = parseBody(CreateProjectNoteSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const authData = request.authData!;
|
||||
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 parsed = parseBody(CreateProjectNoteSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const authData = request.authData!;
|
||||
|
||||
const note = await createProjectNote(projectId, {
|
||||
userId: authData.userId,
|
||||
firstName: authData.firstName,
|
||||
lastName: authData.lastName,
|
||||
content: parsed.data.content ?? undefined,
|
||||
});
|
||||
const note = await createProjectNote(projectId, {
|
||||
userId: authData.userId,
|
||||
firstName: authData.firstName,
|
||||
lastName: authData.lastName,
|
||||
content: parsed.data.content ?? undefined,
|
||||
});
|
||||
|
||||
return success(reply, { note }, 201, 'Poznámka byla přidána');
|
||||
});
|
||||
return success(reply, { note }, 201, "Poznámka byla přidána");
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/projects/next-number — shared sequence with orders (matches PHP)
|
||||
fastify.get('/next-number', { preHandler: requirePermission('projects.create') }, async (_request, reply) => {
|
||||
const nextNumber = await getNextProjectNumber();
|
||||
return success(reply, { next_number: nextNumber });
|
||||
});
|
||||
fastify.get(
|
||||
"/next-number",
|
||||
{ preHandler: requirePermission("projects.create") },
|
||||
async (_request, reply) => {
|
||||
const nextNumber = await getNextProjectNumber();
|
||||
return success(reply, { next_number: nextNumber });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId
|
||||
fastify.delete<{ Params: { id: string; noteId: string } }>('/:id/notes/:noteId', { preHandler: requirePermission('projects.edit') }, async (request, reply) => {
|
||||
const noteId = parseId(request.params.noteId, reply);
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
fastify.delete<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
async (request, reply) => {
|
||||
const noteId = parseId(request.params.noteId, reply);
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
|
||||
const note = await deleteProjectNote(projectId, noteId);
|
||||
if (!note) return error(reply, 'Poznámka nenalezena', 404);
|
||||
const note = await deleteProjectNote(projectId, noteId);
|
||||
if (!note) return error(reply, "Poznámka nenalezena", 404);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project', entityId: projectId, description: `Smazána poznámka projektu` });
|
||||
return success(reply, null, 200, 'Poznámka smazána');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
description: `Smazána poznámka projektu`,
|
||||
});
|
||||
return success(reply, null, 200, "Poznámka smazána");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('projects.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("projects.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const deleteFiles = !!body?.delete_files;
|
||||
const result = await deleteProject(id, deleteFiles);
|
||||
if (result && 'error' in result) {
|
||||
if (result.error === 'not_found') return error(reply, 'Projekt nenalezen', 404);
|
||||
if (result.error === 'has_order') return error(reply, 'Nelze smazat projekt propojený s objednávkou. Nejdříve smažte objednávku.', 400);
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const deleteFiles = !!body?.delete_files;
|
||||
const result = await deleteProject(id, deleteFiles);
|
||||
if (result && "error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Projekt nenalezen", 404);
|
||||
if (result.error === "has_order")
|
||||
return error(
|
||||
reply,
|
||||
"Nelze smazat projekt propojený s objednávkou. Nejdříve smažte objednávku.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'project', entityId: id, description: `Smazán projekt ${(result as any).name}` });
|
||||
return success(reply, null, 200, 'Projekt smazán');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
description: `Smazán projekt ${(result as any).name}`,
|
||||
});
|
||||
return success(reply, null, 200, "Projekt smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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';
|
||||
import prisma from '../../config/database';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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";
|
||||
import prisma from "../../config/database";
|
||||
import {
|
||||
listOffers,
|
||||
getOffer,
|
||||
@@ -15,177 +18,301 @@ import {
|
||||
duplicateOffer,
|
||||
invalidateOffer,
|
||||
getNextOfferNumber,
|
||||
} from '../../services/offers.service';
|
||||
} from "../../services/offers.service";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 10 * 1000; // 10 seconds — lock expires if no heartbeat
|
||||
|
||||
export default async function quotationsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||
|
||||
export default async function quotationsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||
const result = await listOffers({
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort,
|
||||
order,
|
||||
search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
|
||||
const result = await listOffers({
|
||||
page, limit, skip, sort, order, search,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, page, limit) });
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: buildPaginationMeta(result.total, page, limit),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/offers/next-number
|
||||
fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => {
|
||||
const number = await getNextOfferNumber();
|
||||
return success(reply, { number, next_number: number });
|
||||
});
|
||||
fastify.get(
|
||||
"/next-number",
|
||||
{ preHandler: requirePermission("offers.create") },
|
||||
async (_request, reply) => {
|
||||
const number = await getNextOfferNumber();
|
||||
return success(reply, { number, next_number: number });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/offers/:id/duplicate
|
||||
fastify.post<{ Params: { id: string } }>('/:id/duplicate', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/duplicate",
|
||||
{ preHandler: requirePermission("offers.create") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const result = await duplicateOffer(id);
|
||||
if (!result) return error(reply, 'Nabídka nenalezena', 404);
|
||||
const result = await duplicateOffer(id);
|
||||
if (!result) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: result.copy.id, description: `Duplikována nabídka ${result.original.quotation_number} → ${result.copy.quotation_number}` });
|
||||
return success(reply, { id: result.copy.id, quotation_number: result.copy.quotation_number }, 201, 'Nabídka byla duplikována');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "quotation",
|
||||
entityId: result.copy.id,
|
||||
description: `Duplikována nabídka ${result.original.quotation_number} → ${result.copy.quotation_number}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ id: result.copy.id, quotation_number: result.copy.quotation_number },
|
||||
201,
|
||||
"Nabídka byla duplikována",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/offers/:id/invalidate
|
||||
fastify.post<{ Params: { id: string } }>('/:id/invalidate', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/invalidate",
|
||||
{ preHandler: requirePermission("offers.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await invalidateOffer(id);
|
||||
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
|
||||
const existing = await invalidateOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Zneplatněna nabídka ${existing.quotation_number}` });
|
||||
return success(reply, null, 200, 'Nabídka zneplatněna');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Zneplatněna nabídka ${existing.quotation_number}`,
|
||||
});
|
||||
return success(reply, null, 200, "Nabídka zneplatněna");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const data = await getOffer(id);
|
||||
if (!data) return error(reply, 'Nabídka nenalezena', 404);
|
||||
const data = await getOffer(id);
|
||||
if (!data) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
// Include lock info
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
let lockedBy: { user_id: number; username: string; full_name: string } | null = null;
|
||||
if (quotation?.locked_by && quotation?.locked_at) {
|
||||
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
|
||||
if (lockAge < LOCK_TIMEOUT_MS && quotation.locked_by !== request.authData!.userId) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: quotation.locked_by },
|
||||
select: { id: true, username: true, first_name: true, last_name: true },
|
||||
});
|
||||
if (lockUser) {
|
||||
lockedBy = { user_id: lockUser.id, username: lockUser.username, full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim() };
|
||||
// Include lock info
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
let lockedBy: {
|
||||
user_id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null = null;
|
||||
if (quotation?.locked_by && quotation?.locked_at) {
|
||||
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
|
||||
if (
|
||||
lockAge < LOCK_TIMEOUT_MS &&
|
||||
quotation.locked_by !== request.authData!.userId
|
||||
) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: quotation.locked_by },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
},
|
||||
});
|
||||
if (lockUser) {
|
||||
lockedBy = {
|
||||
user_id: lockUser.id,
|
||||
username: lockUser.username,
|
||||
full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success(reply, { ...data, locked_by: lockedBy });
|
||||
});
|
||||
return success(reply, { ...data, locked_by: lockedBy });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/offers/:id/lock — acquire lock
|
||||
fastify.post<{ Params: { id: string } }>('/:id/lock', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/lock",
|
||||
{ preHandler: requirePermission("offers.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
select: { locked_by: true, locked_at: true },
|
||||
});
|
||||
if (!quotation) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
// Check if locked by someone else and lock is fresh
|
||||
if (quotation.locked_by && quotation.locked_at) {
|
||||
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
|
||||
if (lockAge < LOCK_TIMEOUT_MS && quotation.locked_by !== request.authData!.userId) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: quotation.locked_by },
|
||||
select: { first_name: true, last_name: true },
|
||||
});
|
||||
return error(reply, `Nabídku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : 'jiný uživatel'}`, 423);
|
||||
// Check if locked by someone else and lock is fresh
|
||||
if (quotation.locked_by && quotation.locked_at) {
|
||||
const lockAge = Date.now() - new Date(quotation.locked_at).getTime();
|
||||
if (
|
||||
lockAge < LOCK_TIMEOUT_MS &&
|
||||
quotation.locked_by !== request.authData!.userId
|
||||
) {
|
||||
const lockUser = await prisma.users.findUnique({
|
||||
where: { id: quotation.locked_by },
|
||||
select: { first_name: true, last_name: true },
|
||||
});
|
||||
return error(
|
||||
reply,
|
||||
`Nabídku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`,
|
||||
423,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: { locked_by: request.authData!.userId, locked_at: new Date() },
|
||||
});
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: { locked_by: request.authData!.userId, locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Zámek nastaven');
|
||||
});
|
||||
return success(reply, null, 200, "Zámek nastaven");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/offers/:id/heartbeat — keep lock alive
|
||||
fastify.post<{ Params: { id: string } }>('/:id/heartbeat', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/heartbeat",
|
||||
{ preHandler: requirePermission("offers.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_at: new Date() },
|
||||
});
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_at: new Date() },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
});
|
||||
return success(reply, null);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/offers/:id/unlock — release lock
|
||||
fastify.post<{ Params: { id: string } }>('/:id/unlock', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/:id/unlock",
|
||||
{ preHandler: requirePermission("offers.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_by: null, locked_at: null },
|
||||
});
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id, locked_by: request.authData!.userId },
|
||||
data: { locked_by: null, locked_at: null },
|
||||
});
|
||||
|
||||
return success(reply, null);
|
||||
});
|
||||
return success(reply, null);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateQuotationSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.create") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateQuotationSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const quotation = await createOffer(parsed.data);
|
||||
const quotation = await createOffer(parsed.data);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: quotation.id, description: `Vytvořena nabídka ${quotation.quotation_number}` });
|
||||
return success(reply, { id: quotation.id }, 201, 'Nabídka byla vytvořena');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "quotation",
|
||||
entityId: quotation.id,
|
||||
description: `Vytvořena nabídka ${quotation.quotation_number}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ id: quotation.id },
|
||||
201,
|
||||
"Nabídka byla vytvořena",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 parsed = parseBody(UpdateQuotationSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
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 parsed = parseBody(UpdateQuotationSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const result = await updateOffer(id, parsed.data);
|
||||
if ('error' in result) {
|
||||
if (result.error === 'not_found') return error(reply, 'Nabídka nenalezena', 404);
|
||||
if (result.error === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400);
|
||||
}
|
||||
const result = await updateOffer(id, parsed.data);
|
||||
if ("error" in result) {
|
||||
if (result.error === "not_found")
|
||||
return error(reply, "Nabídka nenalezena", 404);
|
||||
if (result.error === "invalidated")
|
||||
return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
|
||||
}
|
||||
|
||||
// Keep lock — user stays on the page after save
|
||||
// Keep lock — user stays on the page after save
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Upravena nabídka ${(result as any).quotation_number}` });
|
||||
return success(reply, { id }, 200, 'Nabídka byla uložena');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Upravena nabídka ${(result as any).quotation_number}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Nabídka byla uložena");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await deleteOffer(id);
|
||||
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
|
||||
const existing = await deleteOffer(id);
|
||||
if (!existing) return error(reply, "Nabídka nenalezena", 404);
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'quotation', entityId: id, description: `Smazána nabídka ${existing.quotation_number}` });
|
||||
return success(reply, null, 200, 'Nabídka smazána');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "quotation",
|
||||
entityId: id,
|
||||
description: `Smazána nabídka ${existing.quotation_number}`,
|
||||
});
|
||||
return success(reply, null, 200, "Nabídka smazána");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,300 +1,503 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { received_invoices_status } from '@prisma/client';
|
||||
import prisma from '../../config/database';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { received_invoices_status } from "@prisma/client";
|
||||
import prisma from "../../config/database";
|
||||
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'];
|
||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"supplier_name",
|
||||
"amount",
|
||||
"issue_date",
|
||||
"due_date",
|
||||
"status",
|
||||
"created_at",
|
||||
];
|
||||
|
||||
export default async function receivedInvoicesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function receivedInvoicesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
await fastify.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
|
||||
fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.year) where.year = Number(query.year);
|
||||
if (query.month) where.month = Number(query.month);
|
||||
if (query.status) where.status = String(query.status);
|
||||
if (query.supplier_name) where.supplier_name = { contains: String(query.supplier_name) };
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.year) where.year = Number(query.year);
|
||||
if (query.month) where.month = Number(query.month);
|
||||
if (query.status) where.status = String(query.status);
|
||||
if (query.supplier_name)
|
||||
where.supplier_name = { contains: String(query.supplier_name) };
|
||||
|
||||
// Search across supplier_name, invoice_number, description
|
||||
if (query.search) {
|
||||
const search = String(query.search);
|
||||
where.OR = [
|
||||
{ supplier_name: { contains: search } },
|
||||
{ invoice_number: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
];
|
||||
}
|
||||
// Search across supplier_name, invoice_number, description
|
||||
if (query.search) {
|
||||
const search = String(query.search);
|
||||
where.OR = [
|
||||
{ supplier_name: { contains: search } },
|
||||
{ invoice_number: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
// Sort field whitelisting
|
||||
const sortField = query.sort && ALLOWED_SORT_FIELDS.includes(String(query.sort)) ? String(query.sort) : 'id';
|
||||
// Sort field whitelisting
|
||||
const sortField =
|
||||
query.sort && ALLOWED_SORT_FIELDS.includes(String(query.sort))
|
||||
? String(query.sort)
|
||||
: "id";
|
||||
|
||||
const [invoices, total] = await Promise.all([
|
||||
prisma.received_invoices.findMany({ where, skip, take: limit, orderBy: { [sortField]: order } }),
|
||||
prisma.received_invoices.count({ where }),
|
||||
]);
|
||||
const [invoices, total] = await Promise.all([
|
||||
prisma.received_invoices.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
}),
|
||||
prisma.received_invoices.count({ where }),
|
||||
]);
|
||||
|
||||
return reply.send({ success: true, data: invoices, pagination: buildPaginationMeta(total, page, limit) });
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: invoices,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/received-invoices/stats
|
||||
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const now = new Date();
|
||||
const year = Number(query.year) || now.getFullYear();
|
||||
const month = Number(query.month) || (now.getMonth() + 1);
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const now = new Date();
|
||||
const year = Number(query.year) || now.getFullYear();
|
||||
const month = Number(query.month) || now.getMonth() + 1;
|
||||
|
||||
const where: Record<string, unknown> = { year, month };
|
||||
const monthInvoices = await prisma.received_invoices.findMany({ where });
|
||||
const where: Record<string, unknown> = { year, month };
|
||||
const monthInvoices = await prisma.received_invoices.findMany({ where });
|
||||
|
||||
// Aggregate by currency → CurrencyAmount[] format
|
||||
const aggregateByCurrency = (invs: typeof monthInvoices, field: 'amount' | 'vat_amount') => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const inv of invs) {
|
||||
const cur = inv.currency || 'CZK';
|
||||
map[cur] = (map[cur] || 0) + (Number(inv[field]) || 0);
|
||||
}
|
||||
return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
|
||||
};
|
||||
// Aggregate by currency → CurrencyAmount[] format
|
||||
const aggregateByCurrency = (
|
||||
invs: typeof monthInvoices,
|
||||
field: "amount" | "vat_amount",
|
||||
) => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const inv of invs) {
|
||||
const cur = inv.currency || "CZK";
|
||||
map[cur] = (map[cur] || 0) + (Number(inv[field]) || 0);
|
||||
}
|
||||
return Object.entries(map)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}));
|
||||
};
|
||||
|
||||
const sumCzk = (invs: typeof monthInvoices, field: 'amount' | 'vat_amount') => {
|
||||
let total = 0;
|
||||
for (const inv of invs) total += Number(inv[field]) || 0;
|
||||
return Math.round(total * 100) / 100;
|
||||
};
|
||||
const sumCzk = (
|
||||
invs: typeof monthInvoices,
|
||||
field: "amount" | "vat_amount",
|
||||
) => {
|
||||
let total = 0;
|
||||
for (const inv of invs) total += Number(inv[field]) || 0;
|
||||
return Math.round(total * 100) / 100;
|
||||
};
|
||||
|
||||
// Also get all-time unpaid
|
||||
const allUnpaid = await prisma.received_invoices.findMany({ where: { status: { not: 'paid' } } });
|
||||
// Also get all-time unpaid
|
||||
const allUnpaid = await prisma.received_invoices.findMany({
|
||||
where: { status: { not: "paid" } },
|
||||
});
|
||||
|
||||
return success(reply, {
|
||||
total_month: aggregateByCurrency(monthInvoices, 'amount'),
|
||||
total_month_czk: sumCzk(monthInvoices, 'amount'),
|
||||
vat_month: aggregateByCurrency(monthInvoices, 'vat_amount'),
|
||||
vat_month_czk: sumCzk(monthInvoices, 'vat_amount'),
|
||||
unpaid: aggregateByCurrency(allUnpaid, 'amount'),
|
||||
unpaid_czk: sumCzk(allUnpaid, 'amount'),
|
||||
unpaid_count: allUnpaid.length,
|
||||
month_count: monthInvoices.length,
|
||||
});
|
||||
});
|
||||
return success(reply, {
|
||||
total_month: aggregateByCurrency(monthInvoices, "amount"),
|
||||
total_month_czk: sumCzk(monthInvoices, "amount"),
|
||||
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
|
||||
vat_month_czk: sumCzk(monthInvoices, "vat_amount"),
|
||||
unpaid: aggregateByCurrency(allUnpaid, "amount"),
|
||||
unpaid_czk: sumCzk(allUnpaid, "amount"),
|
||||
unpaid_count: allUnpaid.length,
|
||||
month_count: monthInvoices.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/received-invoices/suppliers — distinct supplier names for autocomplete
|
||||
fastify.get('/suppliers', { preHandler: requirePermission('invoices.view') }, async (_request, reply) => {
|
||||
const results = await prisma.received_invoices.findMany({
|
||||
select: { supplier_name: true },
|
||||
distinct: ['supplier_name'],
|
||||
orderBy: { supplier_name: 'asc' },
|
||||
});
|
||||
return success(reply, results.map(r => r.supplier_name));
|
||||
});
|
||||
fastify.get(
|
||||
"/suppliers",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (_request, reply) => {
|
||||
const results = await prisma.received_invoices.findMany({
|
||||
select: { supplier_name: true },
|
||||
distinct: ["supplier_name"],
|
||||
orderBy: { supplier_name: "asc" },
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
results.map((r) => r.supplier_name),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/received-invoices/:id/file
|
||||
fastify.get<{ Params: { id: string } }>('/:id/file', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await prisma.received_invoices.findUnique({
|
||||
where: { id },
|
||||
select: { file_data: true, file_name: true, file_mime: true },
|
||||
});
|
||||
if (!invoice?.file_data) return error(reply, 'Soubor nenalezen', 404);
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id/file",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await prisma.received_invoices.findUnique({
|
||||
where: { id },
|
||||
select: { file_data: true, file_name: true, file_mime: true },
|
||||
});
|
||||
if (!invoice?.file_data) return error(reply, "Soubor nenalezen", 404);
|
||||
|
||||
const mime = invoice.file_mime || 'application/pdf';
|
||||
const filename = invoice.file_name || `received-invoice-${id}.pdf`;
|
||||
return reply
|
||||
.type(mime)
|
||||
.header('Content-Disposition', `inline; filename="${filename}"`)
|
||||
.send(Buffer.from(invoice.file_data));
|
||||
});
|
||||
const mime = invoice.file_mime || "application/pdf";
|
||||
const filename = invoice.file_name || `received-invoice-${id}.pdf`;
|
||||
return reply
|
||||
.type(mime)
|
||||
.header("Content-Disposition", `inline; filename="${filename}"`)
|
||||
.send(Buffer.from(invoice.file_data));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await prisma.received_invoices.findUnique({ where: { id } });
|
||||
if (!invoice) return error(reply, 'Přijatá faktura nenalezena', 404);
|
||||
// Don't send file_data in detail response (can be large)
|
||||
const { file_data: _fileData, ...rest } = invoice;
|
||||
return success(reply, rest);
|
||||
});
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const invoice = await prisma.received_invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!invoice) return error(reply, "Přijatá faktura nenalezena", 404);
|
||||
// Don't send file_data in detail response (can be large)
|
||||
const { file_data: _fileData, ...rest } = invoice;
|
||||
return success(reply, rest);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
||||
const contentType = request.headers['content-type'] || '';
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("invoices.create") },
|
||||
async (request, reply) => {
|
||||
const contentType = request.headers["content-type"] || "";
|
||||
|
||||
// Multipart upload: files[] + invoices JSON metadata
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const parts = request.parts();
|
||||
const files: Array<{ data: Buffer; name: string; mime: string; size: number }> = [];
|
||||
let invoicesMeta: Array<Record<string, unknown>> = [];
|
||||
// Multipart upload: files[] + invoices JSON metadata
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const parts = request.parts();
|
||||
const files: Array<{
|
||||
data: Buffer;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
}> = [];
|
||||
let invoicesMeta: Array<Record<string, unknown>> = [];
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file') {
|
||||
const buf = await part.toBuffer();
|
||||
files.push({ data: buf, name: part.filename || 'file', mime: part.mimetype || 'application/octet-stream', size: buf.length });
|
||||
} else if (part.fieldname === 'invoices') {
|
||||
try { invoicesMeta = JSON.parse(part.value as string); } catch { /* ignore parse error */ }
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const buf = await part.toBuffer();
|
||||
files.push({
|
||||
data: buf,
|
||||
name: part.filename || "file",
|
||||
mime: part.mimetype || "application/octet-stream",
|
||||
size: buf.length,
|
||||
});
|
||||
} else if (part.fieldname === "invoices") {
|
||||
try {
|
||||
invoicesMeta = JSON.parse(part.value as string);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0)
|
||||
return error(reply, "Vyberte alespoň jeden soubor", 400);
|
||||
|
||||
const now = new Date();
|
||||
const createdIds: number[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const meta = invoicesMeta[i] || {};
|
||||
const amount = Number(meta.amount ?? 0);
|
||||
const vatRate = Number(meta.vat_rate ?? 21);
|
||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||
const vatAmount =
|
||||
vatRate > 0
|
||||
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
|
||||
: 0;
|
||||
|
||||
const invoice = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: Number(meta.month) || now.getMonth() + 1,
|
||||
year: Number(meta.year) || now.getFullYear(),
|
||||
supplier_name: meta.supplier_name
|
||||
? String(meta.supplier_name)
|
||||
: file.name,
|
||||
invoice_number: meta.invoice_number
|
||||
? String(meta.invoice_number)
|
||||
: null,
|
||||
description: meta.description ? String(meta.description) : null,
|
||||
amount,
|
||||
currency: meta.currency ? String(meta.currency) : "CZK",
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
issue_date: meta.issue_date
|
||||
? new Date(String(meta.issue_date))
|
||||
: null,
|
||||
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
|
||||
status: "unpaid",
|
||||
notes: meta.notes ? String(meta.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
file_data: Uint8Array.from(file.data),
|
||||
file_name: file.name,
|
||||
file_mime: file.mime,
|
||||
file_size: file.size,
|
||||
},
|
||||
});
|
||||
createdIds.push(invoice.id);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "invoice",
|
||||
entityId: createdIds[0],
|
||||
description: `Nahráno ${createdIds.length} přijatých faktur`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ ids: createdIds, count: createdIds.length },
|
||||
201,
|
||||
`Nahráno ${createdIds.length} faktur`,
|
||||
);
|
||||
}
|
||||
|
||||
// JSON body: single invoice creation (no file)
|
||||
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 = body.amount;
|
||||
const vatRate = body.vat_rate;
|
||||
const invoice = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: Number(body.month),
|
||||
year: Number(body.year),
|
||||
supplier_name: String(body.supplier_name),
|
||||
invoice_number: body.invoice_number
|
||||
? String(body.invoice_number)
|
||||
: null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
amount,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: vatRate,
|
||||
vat_amount:
|
||||
vatRate > 0
|
||||
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
|
||||
: 0,
|
||||
issue_date: body.issue_date
|
||||
? new Date(String(body.issue_date))
|
||||
: null,
|
||||
due_date: body.due_date ? new Date(String(body.due_date)) : null,
|
||||
status: status as received_invoices_status,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "invoice",
|
||||
entityId: invoice.id,
|
||||
description: `Vytvořena přijatá faktura od ${invoice.supplier_name}`,
|
||||
});
|
||||
return success(reply, { id: invoice.id }, 201, "Faktura byla vytvořena");
|
||||
},
|
||||
);
|
||||
|
||||
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 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);
|
||||
|
||||
if (body.status !== undefined) {
|
||||
const status = String(body.status);
|
||||
if (
|
||||
!VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])
|
||||
) {
|
||||
return error(reply, "Neplatný stav", 400);
|
||||
}
|
||||
// Prevent reverting paid status (matching PHP)
|
||||
if (String(existing.status) === "paid" && status !== "paid") {
|
||||
return error(reply, "Nelze vrátit stav uhrazené faktury", 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) return error(reply, 'Vyberte alespoň jeden soubor', 400);
|
||||
// Recalculate vat_amount when amount or vat_rate changes (matching PHP)
|
||||
const finalAmount =
|
||||
body.amount !== undefined
|
||||
? Number(body.amount)
|
||||
: Number(existing.amount);
|
||||
const finalVatRate =
|
||||
body.vat_rate !== undefined
|
||||
? Number(body.vat_rate)
|
||||
: Number(existing.vat_rate);
|
||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||
const computedVat =
|
||||
finalVatRate > 0
|
||||
? Math.round(
|
||||
(finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100,
|
||||
) / 100
|
||||
: 0;
|
||||
|
||||
const now = new Date();
|
||||
const createdIds: number[] = [];
|
||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
||||
const newStatus =
|
||||
body.status !== undefined
|
||||
? String(body.status)
|
||||
: String(existing.status);
|
||||
const paidDate =
|
||||
newStatus === "paid" && String(existing.status) !== "paid"
|
||||
? new Date()
|
||||
: body.paid_date !== undefined
|
||||
? body.paid_date
|
||||
? new Date(String(body.paid_date))
|
||||
: null
|
||||
: undefined;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const meta = invoicesMeta[i] || {};
|
||||
const amount = Number(meta.amount ?? 0);
|
||||
const vatRate = Number(meta.vat_rate ?? 21);
|
||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||
const vatAmount = vatRate > 0 ? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 : 0;
|
||||
|
||||
const invoice = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: Number(meta.month) || (now.getMonth() + 1),
|
||||
year: Number(meta.year) || now.getFullYear(),
|
||||
supplier_name: meta.supplier_name ? String(meta.supplier_name) : file.name,
|
||||
invoice_number: meta.invoice_number ? String(meta.invoice_number) : null,
|
||||
description: meta.description ? String(meta.description) : null,
|
||||
amount,
|
||||
currency: meta.currency ? String(meta.currency) : 'CZK',
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
issue_date: meta.issue_date ? new Date(String(meta.issue_date)) : null,
|
||||
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
|
||||
status: 'unpaid',
|
||||
notes: meta.notes ? String(meta.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
file_data: Uint8Array.from(file.data),
|
||||
file_name: file.name,
|
||||
file_mime: file.mime,
|
||||
file_size: file.size,
|
||||
},
|
||||
});
|
||||
createdIds.push(invoice.id);
|
||||
// Auto-update month/year from issue_date if issue_date changes (matching PHP)
|
||||
let autoMonth = body.month !== undefined ? Number(body.month) : undefined;
|
||||
let autoYear = body.year !== undefined ? Number(body.year) : undefined;
|
||||
if (body.issue_date && !body.month && !body.year) {
|
||||
const issueDate = new Date(String(body.issue_date));
|
||||
if (!isNaN(issueDate.getTime())) {
|
||||
autoMonth = issueDate.getMonth() + 1;
|
||||
autoYear = issueDate.getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: createdIds[0], description: `Nahráno ${createdIds.length} přijatých faktur` });
|
||||
return success(reply, { ids: createdIds, count: createdIds.length }, 201, `Nahráno ${createdIds.length} faktur`);
|
||||
}
|
||||
await prisma.received_invoices.update({
|
||||
where: { id },
|
||||
data: {
|
||||
supplier_name:
|
||||
body.supplier_name !== undefined
|
||||
? String(body.supplier_name)
|
||||
: undefined,
|
||||
invoice_number:
|
||||
body.invoice_number !== undefined
|
||||
? body.invoice_number
|
||||
? String(body.invoice_number)
|
||||
: null
|
||||
: undefined,
|
||||
description:
|
||||
body.description !== undefined
|
||||
? body.description
|
||||
? String(body.description)
|
||||
: null
|
||||
: undefined,
|
||||
amount: body.amount !== undefined ? Number(body.amount) : undefined,
|
||||
currency:
|
||||
body.currency !== undefined ? String(body.currency) : undefined,
|
||||
vat_rate:
|
||||
body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||||
vat_amount:
|
||||
body.amount !== undefined || body.vat_rate !== undefined
|
||||
? computedVat
|
||||
: body.vat_amount !== undefined
|
||||
? Number(body.vat_amount)
|
||||
: undefined,
|
||||
issue_date:
|
||||
body.issue_date !== undefined
|
||||
? body.issue_date
|
||||
? new Date(String(body.issue_date))
|
||||
: null
|
||||
: undefined,
|
||||
due_date:
|
||||
body.due_date !== undefined
|
||||
? body.due_date
|
||||
? new Date(String(body.due_date))
|
||||
: null
|
||||
: undefined,
|
||||
paid_date: paidDate,
|
||||
status:
|
||||
body.status !== undefined
|
||||
? (String(body.status) as received_invoices_status)
|
||||
: undefined,
|
||||
notes:
|
||||
body.notes !== undefined
|
||||
? body.notes
|
||||
? String(body.notes)
|
||||
: null
|
||||
: undefined,
|
||||
month: autoMonth,
|
||||
year: autoYear,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
description: `Upravena přijatá faktura`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Faktura byla uložena");
|
||||
},
|
||||
);
|
||||
|
||||
// JSON body: single invoice creation (no file)
|
||||
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);
|
||||
}
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("invoices.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.received_invoices.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
||||
|
||||
const amount = body.amount;
|
||||
const vatRate = body.vat_rate;
|
||||
const invoice = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: Number(body.month),
|
||||
year: Number(body.year),
|
||||
supplier_name: String(body.supplier_name),
|
||||
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
amount,
|
||||
currency: body.currency ? String(body.currency) : 'CZK',
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatRate > 0 ? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 : 0,
|
||||
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
|
||||
due_date: body.due_date ? new Date(String(body.due_date)) : null,
|
||||
status: status as received_invoices_status,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena přijatá faktura od ${invoice.supplier_name}` });
|
||||
return success(reply, { id: invoice.id }, 201, 'Faktura byla vytvořena');
|
||||
});
|
||||
|
||||
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 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);
|
||||
|
||||
if (body.status !== undefined) {
|
||||
const status = String(body.status);
|
||||
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
|
||||
return error(reply, 'Neplatný stav', 400);
|
||||
}
|
||||
// Prevent reverting paid status (matching PHP)
|
||||
if (String(existing.status) === 'paid' && status !== 'paid') {
|
||||
return error(reply, 'Nelze vrátit stav uhrazené faktury', 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate vat_amount when amount or vat_rate changes (matching PHP)
|
||||
const finalAmount = body.amount !== undefined ? Number(body.amount) : Number(existing.amount);
|
||||
const finalVatRate = body.vat_rate !== undefined ? Number(body.vat_rate) : Number(existing.vat_rate);
|
||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||
const computedVat = finalVatRate > 0 ? Math.round((finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100) / 100 : 0;
|
||||
|
||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
||||
const newStatus = body.status !== undefined ? String(body.status) : String(existing.status);
|
||||
const paidDate = newStatus === 'paid' && String(existing.status) !== 'paid'
|
||||
? new Date()
|
||||
: (body.paid_date !== undefined ? (body.paid_date ? new Date(String(body.paid_date)) : null) : undefined);
|
||||
|
||||
// Auto-update month/year from issue_date if issue_date changes (matching PHP)
|
||||
let autoMonth = body.month !== undefined ? Number(body.month) : undefined;
|
||||
let autoYear = body.year !== undefined ? Number(body.year) : undefined;
|
||||
if (body.issue_date && !body.month && !body.year) {
|
||||
const issueDate = new Date(String(body.issue_date));
|
||||
if (!isNaN(issueDate.getTime())) {
|
||||
autoMonth = issueDate.getMonth() + 1;
|
||||
autoYear = issueDate.getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.received_invoices.update({
|
||||
where: { id },
|
||||
data: {
|
||||
supplier_name: body.supplier_name !== undefined ? String(body.supplier_name) : undefined,
|
||||
invoice_number: body.invoice_number !== undefined ? (body.invoice_number ? String(body.invoice_number) : null) : undefined,
|
||||
description: body.description !== undefined ? (body.description ? String(body.description) : null) : undefined,
|
||||
amount: body.amount !== undefined ? Number(body.amount) : undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||||
vat_amount: (body.amount !== undefined || body.vat_rate !== undefined) ? computedVat : (body.vat_amount !== undefined ? Number(body.vat_amount) : undefined),
|
||||
issue_date: body.issue_date !== undefined ? (body.issue_date ? new Date(String(body.issue_date)) : null) : undefined,
|
||||
due_date: body.due_date !== undefined ? (body.due_date ? new Date(String(body.due_date)) : null) : undefined,
|
||||
paid_date: paidDate,
|
||||
status: body.status !== undefined ? String(body.status) as received_invoices_status : undefined,
|
||||
notes: body.notes !== undefined ? (body.notes ? String(body.notes) : null) : undefined,
|
||||
month: autoMonth,
|
||||
year: autoYear,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena přijatá faktura` });
|
||||
return success(reply, { id }, 200, 'Faktura byla uložena');
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.received_invoices.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Přijatá faktura nenalezena', 404);
|
||||
|
||||
await prisma.received_invoices.delete({ where: { id } });
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána přijatá faktura` });
|
||||
return success(reply, null, 200, 'Přijatá faktura smazána');
|
||||
});
|
||||
await prisma.received_invoices.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
description: `Smazána přijatá faktura`,
|
||||
});
|
||||
return success(reply, null, 200, "Přijatá faktura smazána");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,136 +1,165 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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<void> {
|
||||
export default async function rolesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// GET /api/admin/roles
|
||||
fastify.get('/', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
|
||||
const roles = await prisma.roles.findMany({
|
||||
include: {
|
||||
role_permissions: {
|
||||
include: { permissions: true },
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (request, reply) => {
|
||||
const roles = await prisma.roles.findMany({
|
||||
include: {
|
||||
role_permissions: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
const data = roles.map(r => ({
|
||||
...r,
|
||||
permissions: r.role_permissions.map(rp => rp.permissions),
|
||||
}));
|
||||
const data = roles.map((r) => ({
|
||||
...r,
|
||||
permissions: r.role_permissions.map((rp) => rp.permissions),
|
||||
}));
|
||||
|
||||
return success(reply, data);
|
||||
});
|
||||
return success(reply, data);
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/roles/permissions
|
||||
fastify.get('/permissions', { preHandler: requirePermission('settings.roles') }, async (_request, reply) => {
|
||||
const permissions = await prisma.permissions.findMany({ orderBy: { module: 'asc' } });
|
||||
return success(reply, permissions);
|
||||
});
|
||||
fastify.get(
|
||||
"/permissions",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (_request, reply) => {
|
||||
const permissions = await prisma.permissions.findMany({
|
||||
orderBy: { module: "asc" },
|
||||
});
|
||||
return success(reply, permissions);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/roles
|
||||
fastify.post('/', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateRoleSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (request, reply) => {
|
||||
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: {
|
||||
name: String(body.name),
|
||||
display_name: String(body.display_name),
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: role.id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: String(body.name),
|
||||
display_name: String(body.display_name),
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'create',
|
||||
entityType: 'role',
|
||||
entityId: role.id,
|
||||
description: `Vytvořena role ${role.name}`,
|
||||
});
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: role.id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return success(reply, { id: role.id }, 201, 'Role byla vytvořena');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "role",
|
||||
entityId: role.id,
|
||||
description: `Vytvořena role ${role.name}`,
|
||||
});
|
||||
|
||||
return success(reply, { id: role.id }, 201, "Role byla vytvořena");
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/admin/roles/:id
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateRoleSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
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);
|
||||
const existing = await prisma.roles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Role nenalezena", 404);
|
||||
|
||||
await prisma.roles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
display_name: body.display_name ? String(body.display_name) : undefined,
|
||||
description: body.description !== undefined ? String(body.description) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
await prisma.roles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
display_name: body.display_name
|
||||
? String(body.display_name)
|
||||
: undefined,
|
||||
description:
|
||||
body.description !== undefined
|
||||
? String(body.description)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'update',
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
description: `Upravena role ${existing.name}`,
|
||||
});
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.deleteMany({ where: { role_id: id } });
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return success(reply, { id }, 200, 'Role byla aktualizována');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "role",
|
||||
entityId: id,
|
||||
description: `Upravena role ${existing.name}`,
|
||||
});
|
||||
|
||||
return success(reply, { id }, 200, "Role byla aktualizována");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/roles/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('settings.roles') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const existing = await prisma.roles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Role nenalezena', 404);
|
||||
const existing = await prisma.roles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Role nenalezena", 404);
|
||||
|
||||
if (existing.name === 'admin') {
|
||||
return error(reply, 'Nelze smazat roli admin', 400);
|
||||
}
|
||||
if (existing.name === "admin") {
|
||||
return error(reply, "Nelze smazat roli admin", 400);
|
||||
}
|
||||
|
||||
await prisma.roles.delete({ where: { id } });
|
||||
await prisma.roles.delete({ where: { id } });
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'delete',
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
description: `Smazána role ${existing.name}`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "role",
|
||||
entityId: id,
|
||||
description: `Smazána role ${existing.name}`,
|
||||
});
|
||||
|
||||
return success(reply, { id }, 200, 'Role byla smazána');
|
||||
});
|
||||
return success(reply, { id }, 200, "Role byla smazána");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,158 +1,225 @@
|
||||
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';
|
||||
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 }
|
||||
interface ScopeSectionInput {
|
||||
title?: string;
|
||||
title_cz?: string;
|
||||
content?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export default async function scopeTemplatesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function scopeTemplatesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// Legacy ?action= dispatcher for item templates
|
||||
fastify.get('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const action = query.action ? String(query.action) : null;
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const action = query.action ? String(query.action) : null;
|
||||
|
||||
// Item templates
|
||||
if (action === 'items') {
|
||||
const items = await prisma.item_templates.findMany({
|
||||
where: { is_deleted: false },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return success(reply, items);
|
||||
}
|
||||
|
||||
// Default: scope templates
|
||||
const templates = await prisma.scope_templates.findMany({
|
||||
where: { is_deleted: false },
|
||||
include: { scope_template_sections: { where: { is_deleted: false }, orderBy: { position: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return success(reply, templates);
|
||||
});
|
||||
|
||||
// Item template CRUD via ?action=item
|
||||
fastify.post('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
|
||||
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,
|
||||
default_price: body.default_price != null ? Number(body.default_price) : 0,
|
||||
category: body.category ? String(body.category) : null,
|
||||
};
|
||||
|
||||
// Update existing item if id is provided
|
||||
if (body.id) {
|
||||
const existingItem = await prisma.item_templates.findUnique({ where: { id: Number(body.id) } });
|
||||
if (!existingItem) return error(reply, 'Šablona nenalezena', 404);
|
||||
await prisma.item_templates.update({
|
||||
where: { id: Number(body.id) },
|
||||
data: { ...itemData, modified_at: new Date() },
|
||||
// Item templates
|
||||
if (action === "items") {
|
||||
const items = await prisma.item_templates.findMany({
|
||||
where: { is_deleted: false },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return success(reply, { id: Number(body.id) }, 200, 'Položka byla uložena');
|
||||
return success(reply, items);
|
||||
}
|
||||
|
||||
const item = await prisma.item_templates.create({ data: itemData });
|
||||
return success(reply, { id: item.id }, 201, 'Položka byla vytvořena');
|
||||
}
|
||||
|
||||
// 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,
|
||||
title: body.title ? String(body.title) : null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: template.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
// Default: scope templates
|
||||
const templates = await prisma.scope_templates.findMany({
|
||||
where: { is_deleted: false },
|
||||
include: {
|
||||
scope_template_sections: {
|
||||
where: { is_deleted: false },
|
||||
orderBy: { position: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
return success(reply, templates);
|
||||
},
|
||||
);
|
||||
|
||||
return success(reply, { id: template.id }, 201, 'Šablona byla vytvořena');
|
||||
});
|
||||
// Item template CRUD via ?action=item
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
|
||||
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,
|
||||
default_price:
|
||||
body.default_price != null ? Number(body.default_price) : 0,
|
||||
category: body.category ? String(body.category) : null,
|
||||
};
|
||||
|
||||
// Update existing item if id is provided
|
||||
if (body.id) {
|
||||
const existingItem = await prisma.item_templates.findUnique({
|
||||
where: { id: Number(body.id) },
|
||||
});
|
||||
if (!existingItem) return error(reply, "Šablona nenalezena", 404);
|
||||
await prisma.item_templates.update({
|
||||
where: { id: Number(body.id) },
|
||||
data: { ...itemData, modified_at: new Date() },
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ id: Number(body.id) },
|
||||
200,
|
||||
"Položka byla uložena",
|
||||
);
|
||||
}
|
||||
|
||||
const item = await prisma.item_templates.create({ data: itemData });
|
||||
return success(reply, { id: item.id }, 201, "Položka byla vytvořena");
|
||||
}
|
||||
|
||||
// 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,
|
||||
title: body.title ? String(body.title) : null,
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: template.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return success(reply, { id: template.id }, 201, "Šablona byla vytvořena");
|
||||
},
|
||||
);
|
||||
|
||||
// Item template delete via DELETE ?action=item&id=X
|
||||
fastify.delete('/', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
fastify.delete(
|
||||
"/",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
|
||||
if (String(query.action) === 'item' && query.id) {
|
||||
const id = Number(query.id);
|
||||
await prisma.item_templates.update({ where: { id }, data: { is_deleted: true, modified_at: new Date() } });
|
||||
return success(reply, null, 200, 'Šablona smazána');
|
||||
}
|
||||
if (String(query.action) === "item" && query.id) {
|
||||
const id = Number(query.id);
|
||||
await prisma.item_templates.update({
|
||||
where: { id },
|
||||
data: { is_deleted: true, modified_at: new Date() },
|
||||
});
|
||||
return success(reply, null, 200, "Šablona smazána");
|
||||
}
|
||||
|
||||
return error(reply, 'Neplatná akce', 400);
|
||||
});
|
||||
return error(reply, "Neplatná akce", 400);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const template = await prisma.scope_templates.findUnique({
|
||||
where: { id },
|
||||
include: { scope_template_sections: { where: { is_deleted: false }, orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!template || template.is_deleted) return error(reply, 'Šablona nenalezena', 404);
|
||||
return success(reply, template);
|
||||
});
|
||||
|
||||
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 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);
|
||||
|
||||
await prisma.scope_templates.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
title: body.title !== undefined ? String(body.title) : undefined,
|
||||
description: body.description !== undefined ? String(body.description) : undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_template_sections.deleteMany({ where: { scope_template_id: id } });
|
||||
await prisma.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const template = await prisma.scope_templates.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
scope_template_sections: {
|
||||
where: { is_deleted: false },
|
||||
orderBy: { position: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!template || template.is_deleted)
|
||||
return error(reply, "Šablona nenalezena", 404);
|
||||
return success(reply, template);
|
||||
},
|
||||
);
|
||||
|
||||
return success(reply, { id }, 200, 'Šablona byla uložena');
|
||||
});
|
||||
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 parsed = parseBody(UpdateScopeTemplateSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.settings') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
await prisma.scope_templates.update({ where: { id }, data: { is_deleted: true, modified_at: new Date() } });
|
||||
return success(reply, null, 200, 'Šablona smazána');
|
||||
});
|
||||
const existing = await prisma.scope_templates.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) return error(reply, "Šablona nenalezena", 404);
|
||||
|
||||
await prisma.scope_templates.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
title: body.title !== undefined ? String(body.title) : undefined,
|
||||
description:
|
||||
body.description !== undefined
|
||||
? String(body.description)
|
||||
: undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_template_sections.deleteMany({
|
||||
where: { scope_template_id: id },
|
||||
});
|
||||
await prisma.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return success(reply, { id }, 200, "Šablona byla uložena");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
await prisma.scope_templates.update({
|
||||
where: { id },
|
||||
data: { is_deleted: true, modified_at: new Date() },
|
||||
});
|
||||
return success(reply, null, 200, "Šablona smazána");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,82 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import crypto from 'crypto';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
import { success, error } from '../../utils/response';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import crypto from "crypto";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth } from "../../middleware/auth";
|
||||
import { success, error } from "../../utils/response";
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** Parse user-agent string into browser, OS, and device icon */
|
||||
function parseUserAgent(ua: string | null): { browser: string; os: string; icon: string } {
|
||||
if (!ua) return { browser: 'Neznámý prohlížeč', os: 'Neznámý systém', icon: 'monitor' };
|
||||
function parseUserAgent(ua: string | null): {
|
||||
browser: string;
|
||||
os: string;
|
||||
icon: string;
|
||||
} {
|
||||
if (!ua)
|
||||
return {
|
||||
browser: "Neznámý prohlížeč",
|
||||
os: "Neznámý systém",
|
||||
icon: "monitor",
|
||||
};
|
||||
|
||||
// Browser detection
|
||||
let browser = 'Neznámý prohlížeč';
|
||||
if (ua.includes('Edg/')) browser = 'Edge';
|
||||
else if (ua.includes('OPR/') || ua.includes('Opera')) browser = 'Opera';
|
||||
else if (ua.includes('Chrome/')) browser = 'Chrome';
|
||||
else if (ua.includes('Safari/') && !ua.includes('Chrome')) browser = 'Safari';
|
||||
else if (ua.includes('Firefox/')) browser = 'Firefox';
|
||||
let browser = "Neznámý prohlížeč";
|
||||
if (ua.includes("Edg/")) browser = "Edge";
|
||||
else if (ua.includes("OPR/") || ua.includes("Opera")) browser = "Opera";
|
||||
else if (ua.includes("Chrome/")) browser = "Chrome";
|
||||
else if (ua.includes("Safari/") && !ua.includes("Chrome")) browser = "Safari";
|
||||
else if (ua.includes("Firefox/")) browser = "Firefox";
|
||||
|
||||
// OS detection
|
||||
let os = 'Neznámý systém';
|
||||
if (ua.includes('Windows')) os = 'Windows';
|
||||
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
|
||||
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
||||
let os = "Neznámý systém";
|
||||
if (ua.includes("Windows")) os = "Windows";
|
||||
else if (ua.includes("Mac OS X") || ua.includes("Macintosh")) os = "macOS";
|
||||
else if (ua.includes("Linux") && !ua.includes("Android")) os = "Linux";
|
||||
else if (ua.includes("Android")) os = "Android";
|
||||
else if (ua.includes("iPhone") || ua.includes("iPad")) os = "iOS";
|
||||
|
||||
// Device icon
|
||||
let icon = 'monitor';
|
||||
if (ua.includes('Mobile') || ua.includes('iPhone') || ua.includes('Android')) {
|
||||
icon = ua.includes('iPad') || ua.includes('Tablet') ? 'tablet' : 'smartphone';
|
||||
let icon = "monitor";
|
||||
if (
|
||||
ua.includes("Mobile") ||
|
||||
ua.includes("iPhone") ||
|
||||
ua.includes("Android")
|
||||
) {
|
||||
icon =
|
||||
ua.includes("iPad") || ua.includes("Tablet") ? "tablet" : "smartphone";
|
||||
}
|
||||
|
||||
return { browser, os, icon };
|
||||
}
|
||||
|
||||
export default async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function sessionsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// GET /api/admin/sessions — list active sessions for current user
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const currentToken = request.cookies?.refresh_token;
|
||||
const currentHash = currentToken ? hashToken(currentToken) : null;
|
||||
|
||||
const sessions = await prisma.refresh_tokens.findMany({
|
||||
where: { user_id: authData.userId, replaced_at: null, expires_at: { gt: new Date() } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
where: {
|
||||
user_id: authData.userId,
|
||||
replaced_at: null,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
});
|
||||
|
||||
const enriched = sessions.map(s => {
|
||||
const enriched = sessions.map((s) => {
|
||||
const device_info = parseUserAgent(s.user_agent);
|
||||
return {
|
||||
id: s.id,
|
||||
is_current: currentHash ? s.token_hash === currentHash : false,
|
||||
device_info,
|
||||
ip_address: s.ip_address || '',
|
||||
created_at: s.created_at ? s.created_at.toISOString() : '',
|
||||
ip_address: s.ip_address || "",
|
||||
created_at: s.created_at ? s.created_at.toISOString() : "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -64,28 +84,32 @@ export default async function sessionsRoutes(fastify: FastifyInstance): Promise<
|
||||
});
|
||||
|
||||
// DELETE /api/admin/sessions/:id — delete specific session
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const authData = request.authData!;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const authData = request.authData!;
|
||||
|
||||
const session = await prisma.refresh_tokens.findFirst({
|
||||
where: { id, user_id: authData.userId },
|
||||
});
|
||||
if (!session) return error(reply, 'Relace nenalezena', 404);
|
||||
const session = await prisma.refresh_tokens.findFirst({
|
||||
where: { id, user_id: authData.userId },
|
||||
});
|
||||
if (!session) return error(reply, "Relace nenalezena", 404);
|
||||
|
||||
await prisma.refresh_tokens.update({
|
||||
where: { id },
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
return success(reply, null, 200, 'Relace ukončena');
|
||||
});
|
||||
await prisma.refresh_tokens.update({
|
||||
where: { id },
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
return success(reply, null, 200, "Relace ukončena");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/sessions — delete all sessions except current
|
||||
fastify.delete('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.delete("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authData = request.authData!;
|
||||
const query = request.query as Record<string, unknown>;
|
||||
|
||||
if (query.action === 'all') {
|
||||
if (query.action === "all") {
|
||||
// Get current token from cookie to exclude (hash it to match stored token_hash)
|
||||
const currentToken = request.cookies?.refresh_token;
|
||||
const currentHash = currentToken ? hashToken(currentToken) : null;
|
||||
@@ -98,9 +122,9 @@ export default async function sessionsRoutes(fastify: FastifyInstance): Promise<
|
||||
},
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
return success(reply, null, 200, 'Všechny ostatní relace ukončeny');
|
||||
return success(reply, null, 200, "Všechny ostatní relace ukončeny");
|
||||
}
|
||||
|
||||
return error(reply, 'Neplatná akce', 400);
|
||||
return error(reply, "Neplatná akce", 400);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth, requirePermission } from '../../middleware/auth';
|
||||
import { success, error } from '../../utils/response';
|
||||
import { encrypt } from '../../utils/encryption';
|
||||
import { OTPAuth } from '../../utils/totp';
|
||||
import * as OTPAuthLib from 'otpauth';
|
||||
import { logAudit } from '../../services/audit';
|
||||
import { parseBody } from '../../schemas/common';
|
||||
import { TotpBackupSchema } from '../../schemas/auth.schema';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { encrypt } from "../../utils/encryption";
|
||||
import { OTPAuth } from "../../utils/totp";
|
||||
import * as OTPAuthLib from "otpauth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { TotpBackupSchema } from "../../schemas/auth.schema";
|
||||
|
||||
export default async function totpRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function totpRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// GET - generate new TOTP secret
|
||||
fastify.get('/setup', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.get("/setup", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const secret = new OTPAuthLib.Secret();
|
||||
const totp = new OTPAuthLib.TOTP({
|
||||
issuer: 'BOHA Automation',
|
||||
issuer: "BOHA Automation",
|
||||
label: request.authData!.email,
|
||||
secret,
|
||||
algorithm: 'SHA1',
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
@@ -31,206 +33,273 @@ export default async function totpRoutes(fastify: FastifyInstance): Promise<void
|
||||
});
|
||||
|
||||
// POST - enable TOTP
|
||||
fastify.post('/enable', { preHandler: requireAuth, bodyLimit: 10240 }, async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const { secret, code } = body;
|
||||
fastify.post(
|
||||
"/enable",
|
||||
{ preHandler: requireAuth, bodyLimit: 10240 },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const { secret, code } = body;
|
||||
|
||||
if (!secret || !code) {
|
||||
return error(reply, 'Secret a kód jsou povinné', 400);
|
||||
}
|
||||
if (!secret || !code) {
|
||||
return error(reply, "Secret a kód jsou povinné", 400);
|
||||
}
|
||||
|
||||
// Verify the code first
|
||||
const totp = new OTPAuthLib.TOTP({
|
||||
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
// Verify the code first
|
||||
const totp = new OTPAuthLib.TOTP({
|
||||
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: String(code), window: 1 });
|
||||
if (delta === null) {
|
||||
return error(reply, 'Neplatný TOTP kód', 400);
|
||||
}
|
||||
const delta = totp.validate({ token: String(code), window: 1 });
|
||||
if (delta === null) {
|
||||
return error(reply, "Neplatný TOTP kód", 400);
|
||||
}
|
||||
|
||||
// Generate 8 backup codes
|
||||
const backupCodesPlain: string[] = [];
|
||||
const backupCodesHashed: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||
backupCodesPlain.push(code);
|
||||
backupCodesHashed.push(bcrypt.hashSync(code, 10));
|
||||
}
|
||||
// Generate 8 backup codes
|
||||
const backupCodesPlain: string[] = [];
|
||||
const backupCodesHashed: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||
backupCodesPlain.push(code);
|
||||
backupCodesHashed.push(bcrypt.hashSync(code, 10));
|
||||
}
|
||||
|
||||
// Encrypt and store
|
||||
const encryptedSecret = encrypt(String(secret));
|
||||
await prisma.users.update({
|
||||
where: { id: request.authData!.userId },
|
||||
data: {
|
||||
totp_secret: encryptedSecret,
|
||||
totp_enabled: true,
|
||||
totp_backup_codes: JSON.stringify(backupCodesHashed),
|
||||
},
|
||||
});
|
||||
// Encrypt and store
|
||||
const encryptedSecret = encrypt(String(secret));
|
||||
await prisma.users.update({
|
||||
where: { id: request.authData!.userId },
|
||||
data: {
|
||||
totp_secret: encryptedSecret,
|
||||
totp_enabled: true,
|
||||
totp_backup_codes: JSON.stringify(backupCodesHashed),
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'user', entityId: request.authData!.userId, description: '2FA aktivováno' });
|
||||
return success(reply, { backup_codes: backupCodesPlain }, 200, '2FA aktivováno');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "user",
|
||||
entityId: request.authData!.userId,
|
||||
description: "2FA aktivováno",
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ backup_codes: backupCodesPlain },
|
||||
200,
|
||||
"2FA aktivováno",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// PUT - disable TOTP
|
||||
fastify.put('/disable', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
fastify.put(
|
||||
"/disable",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
if (!body.code) {
|
||||
return error(reply, 'TOTP kód je povinný pro deaktivaci', 400);
|
||||
}
|
||||
if (!body.code) {
|
||||
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({ where: { id: request.authData!.userId } });
|
||||
if (!user?.totp_secret) {
|
||||
return error(reply, '2FA není aktivní', 400);
|
||||
}
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
});
|
||||
if (!user?.totp_secret) {
|
||||
return error(reply, "2FA není aktivní", 400);
|
||||
}
|
||||
|
||||
const isValid = OTPAuth.verify(user.totp_secret, String(body.code));
|
||||
if (!isValid) {
|
||||
return error(reply, 'Neplatný TOTP kód', 400);
|
||||
}
|
||||
const isValid = OTPAuth.verify(user.totp_secret, String(body.code));
|
||||
if (!isValid) {
|
||||
return error(reply, "Neplatný TOTP kód", 400);
|
||||
}
|
||||
|
||||
await prisma.users.update({
|
||||
where: { id: request.authData!.userId },
|
||||
data: { totp_secret: null, totp_enabled: false, totp_backup_codes: null },
|
||||
});
|
||||
await prisma.users.update({
|
||||
where: { id: request.authData!.userId },
|
||||
data: {
|
||||
totp_secret: null,
|
||||
totp_enabled: false,
|
||||
totp_backup_codes: null,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'user', entityId: request.authData!.userId, description: '2FA deaktivováno' });
|
||||
return success(reply, null, 200, '2FA deaktivováno');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "user",
|
||||
entityId: request.authData!.userId,
|
||||
description: "2FA deaktivováno",
|
||||
});
|
||||
return success(reply, null, 200, "2FA deaktivováno");
|
||||
},
|
||||
);
|
||||
|
||||
// GET - TOTP status for current user
|
||||
fastify.get('/status', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
select: { totp_enabled: true },
|
||||
});
|
||||
fastify.get(
|
||||
"/status",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
select: { totp_enabled: true },
|
||||
});
|
||||
|
||||
return success(reply, { totp_enabled: user?.totp_enabled ?? false });
|
||||
});
|
||||
return success(reply, { totp_enabled: user?.totp_enabled ?? false });
|
||||
},
|
||||
);
|
||||
|
||||
// GET - check if 2FA is required company-wide
|
||||
fastify.get('/required', { preHandler: [requireAuth, requirePermission('settings.security')] }, async (request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { require_2fa: true },
|
||||
});
|
||||
fastify.get(
|
||||
"/required",
|
||||
{ preHandler: [requireAuth, requirePermission("settings.security")] },
|
||||
async (request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { require_2fa: true },
|
||||
});
|
||||
|
||||
return success(reply, { require_2fa: settings?.require_2fa ?? false });
|
||||
});
|
||||
return success(reply, { require_2fa: settings?.require_2fa ?? false });
|
||||
},
|
||||
);
|
||||
|
||||
// POST - toggle mandatory 2FA
|
||||
fastify.post('/required', { preHandler: [requireAuth, requirePermission('settings.security')], bodyLimit: 10240 }, async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
fastify.post(
|
||||
"/required",
|
||||
{
|
||||
preHandler: [requireAuth, requirePermission("settings.security")],
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const required = body.required === true || body.required === 1 || body.required === '1';
|
||||
await prisma.company_settings.updateMany({
|
||||
data: { require_2fa: required },
|
||||
});
|
||||
const required =
|
||||
body.required === true || body.required === 1 || body.required === "1";
|
||||
await prisma.company_settings.updateMany({
|
||||
data: { require_2fa: required },
|
||||
});
|
||||
|
||||
const message = required
|
||||
? '2FA je nyní povinné pro všechny uživatele'
|
||||
: '2FA již není povinné';
|
||||
const message = required
|
||||
? "2FA je nyní povinné pro všechny uživatele"
|
||||
: "2FA již není povinné";
|
||||
|
||||
return success(reply, null, 200, message);
|
||||
});
|
||||
return success(reply, null, 200, message);
|
||||
},
|
||||
);
|
||||
|
||||
// POST - verify backup code (pre-auth, no requireAuth)
|
||||
fastify.post('/backup-verify', { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, backup_code: code } = parsed.data;
|
||||
fastify.post(
|
||||
"/backup-verify",
|
||||
{ bodyLimit: 10240 },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, backup_code: code } = parsed.data;
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(login_token).digest('hex');
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(login_token)
|
||||
.digest("hex");
|
||||
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
const storedToken = await prisma.totp_login_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, 'Neplatný nebo expirovaný login token', 401);
|
||||
}
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user || !user.totp_backup_codes) {
|
||||
return error(reply, 'Uživatel nenalezen', 401);
|
||||
}
|
||||
|
||||
const backupCodes: string[] = JSON.parse(user.totp_backup_codes as string);
|
||||
let matchIndex = -1;
|
||||
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
return error(reply, "Neplatný nebo expirovaný login token", 401);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return error(reply, 'Neplatný záložní kód', 401);
|
||||
}
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: storedToken.user_id },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
// Remove used backup code
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totp_backup_codes: JSON.stringify(backupCodes),
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
if (!user || !user.totp_backup_codes) {
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
const backupCodes: string[] = JSON.parse(
|
||||
user.totp_backup_codes as string,
|
||||
);
|
||||
let matchIndex = -1;
|
||||
|
||||
// Create tokens (same as /login/totp flow)
|
||||
const { loadAuthData } = await import('../../services/auth');
|
||||
const authData = await loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return error(reply, 'Chyba načítání uživatele', 500);
|
||||
}
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { config } = await import('../../config/env');
|
||||
if (matchIndex === -1) {
|
||||
return error(reply, "Neplatný záložní kód", 401);
|
||||
}
|
||||
|
||||
const accessToken = jwt.default.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roles?.name ?? null },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
// Remove used backup code
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totp_backup_codes: JSON.stringify(backupCodes),
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const refreshTokenRaw = crypto.randomBytes(32).toString('hex');
|
||||
const refreshTokenHash = crypto.createHash('sha256').update(refreshTokenRaw).digest('hex');
|
||||
// Delete used login token
|
||||
await prisma.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(Date.now() + config.jwt.refreshTokenSessionExpiry * 1000),
|
||||
remember_me: false,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers['user-agent'] ?? null,
|
||||
},
|
||||
});
|
||||
// Create tokens (same as /login/totp flow)
|
||||
const { loadAuthData } = await import("../../services/auth");
|
||||
const authData = await loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return error(reply, "Chyba načítání uživatele", 500);
|
||||
}
|
||||
|
||||
reply.setCookie('refresh_token', refreshTokenRaw, {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/admin',
|
||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
||||
});
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const { config } = await import("../../config/env");
|
||||
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
});
|
||||
const accessToken = jwt.default.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.roles?.name ?? null,
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.accessTokenExpiry },
|
||||
);
|
||||
|
||||
const refreshTokenRaw = crypto.randomBytes(32).toString("hex");
|
||||
const refreshTokenHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(refreshTokenRaw)
|
||||
.digest("hex");
|
||||
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(
|
||||
Date.now() + config.jwt.refreshTokenSessionExpiry * 1000,
|
||||
),
|
||||
remember_me: false,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.setCookie("refresh_token", refreshTokenRaw, {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
path: "/api/admin",
|
||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
||||
});
|
||||
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
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<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
export default async function tripsRoutes(
|
||||
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('trips.admin');
|
||||
const isAdmin = authData.permissions.includes("trips.admin");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (!isAdmin) where.user_id = authData.userId;
|
||||
@@ -23,9 +25,9 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise<voi
|
||||
if (query.month) {
|
||||
const monthStr = String(query.month);
|
||||
let yr: number, mo: number;
|
||||
if (monthStr.includes('-')) {
|
||||
if (monthStr.includes("-")) {
|
||||
// Combined YYYY-MM format
|
||||
const [yStr, mStr] = monthStr.split('-');
|
||||
const [yStr, mStr] = monthStr.split("-");
|
||||
yr = Number(yStr);
|
||||
mo = Number(mStr);
|
||||
} else if (query.year) {
|
||||
@@ -45,7 +47,10 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise<voi
|
||||
|
||||
const [trips, total] = await Promise.all([
|
||||
prisma.trips.findMany({
|
||||
where, skip, take: limit, orderBy: { trip_date: order },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { trip_date: order },
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
@@ -54,87 +59,111 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise<voi
|
||||
prisma.trips.count({ where }),
|
||||
]);
|
||||
|
||||
return reply.send({ success: true, data: trips, pagination: buildPaginationMeta(total, page, limit) });
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: trips,
|
||||
pagination: buildPaginationMeta(total, page, limit),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/admin/trips/print — print data for trip report
|
||||
fastify.get('/print', { preHandler: requirePermission('trips.admin') }, async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
||||
const filterVehicleId = query.vehicle_id ? Number(query.vehicle_id) : null;
|
||||
fastify.get(
|
||||
"/print",
|
||||
{ preHandler: requirePermission("trips.admin") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
||||
const filterVehicleId = query.vehicle_id
|
||||
? Number(query.vehicle_id)
|
||||
: null;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filterUserId) where.user_id = filterUserId;
|
||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
||||
if (query.month && query.year) {
|
||||
where.trip_date = {
|
||||
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
|
||||
lt: new Date(Number(query.year), Number(query.month), 1),
|
||||
};
|
||||
}
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filterUserId) where.user_id = filterUserId;
|
||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
||||
if (query.month && query.year) {
|
||||
where.trip_date = {
|
||||
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
|
||||
lt: new Date(Number(query.year), Number(query.month), 1),
|
||||
};
|
||||
}
|
||||
|
||||
const trips = await prisma.trips.findMany({
|
||||
where,
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
},
|
||||
orderBy: { trip_date: 'asc' },
|
||||
});
|
||||
const trips = await prisma.trips.findMany({
|
||||
where,
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
},
|
||||
orderBy: { trip_date: "asc" },
|
||||
});
|
||||
|
||||
const vehicles = await prisma.vehicles.findMany({ orderBy: { name: 'asc' } });
|
||||
const users = await prisma.users.findMany({
|
||||
where: { is_active: true },
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
orderBy: { last_name: 'asc' },
|
||||
});
|
||||
const vehicles = await prisma.vehicles.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const users = await prisma.users.findMany({
|
||||
where: { is_active: true },
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
orderBy: { last_name: "asc" },
|
||||
});
|
||||
|
||||
let totalKm = 0;
|
||||
let businessKm = 0;
|
||||
let privateKm = 0;
|
||||
for (const t of trips) {
|
||||
const dist = Number(t.end_km) - Number(t.start_km);
|
||||
totalKm += dist;
|
||||
if (t.is_business) businessKm += dist;
|
||||
else privateKm += dist;
|
||||
}
|
||||
let totalKm = 0;
|
||||
let businessKm = 0;
|
||||
let privateKm = 0;
|
||||
for (const t of trips) {
|
||||
const dist = Number(t.end_km) - Number(t.start_km);
|
||||
totalKm += dist;
|
||||
if (t.is_business) businessKm += dist;
|
||||
else privateKm += dist;
|
||||
}
|
||||
|
||||
return success(reply, {
|
||||
trips,
|
||||
vehicles,
|
||||
users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })),
|
||||
totals: { total_km: totalKm, business_km: businessKm, private_km: privateKm, count: trips.length },
|
||||
});
|
||||
});
|
||||
return success(reply, {
|
||||
trips,
|
||||
vehicles,
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
name: `${u.first_name} ${u.last_name}`.trim(),
|
||||
})),
|
||||
totals: {
|
||||
total_km: totalKm,
|
||||
business_km: businessKm,
|
||||
private_km: privateKm,
|
||||
count: trips.length,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/trips/last-km/:vehicleId
|
||||
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
|
||||
fastify.get<{ Params: { vehicleId: string } }>('/last-km/:vehicleId', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const vehicleId = parseInt(request.params.vehicleId, 10);
|
||||
if (isNaN(vehicleId)) return error(reply, 'Neplatné ID vozidla', 400);
|
||||
fastify.get<{ Params: { vehicleId: string } }>(
|
||||
"/last-km/:vehicleId",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const vehicleId = parseInt(request.params.vehicleId, 10);
|
||||
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
|
||||
|
||||
const [lastTrip, vehicle] = await Promise.all([
|
||||
prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: 'desc' },
|
||||
select: { end_km: true },
|
||||
}),
|
||||
prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
}),
|
||||
]);
|
||||
const [lastTrip, vehicle] = await Promise.all([
|
||||
prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
}),
|
||||
prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const lastKm = lastTrip
|
||||
? Number(lastTrip.end_km)
|
||||
: Number(vehicle?.initial_km ?? 0);
|
||||
const lastKm = lastTrip
|
||||
? Number(lastTrip.end_km)
|
||||
: Number(vehicle?.initial_km ?? 0);
|
||||
|
||||
return success(reply, { last_km: lastKm });
|
||||
});
|
||||
return success(reply, { last_km: lastKm });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
|
||||
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateTripSchema, 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!;
|
||||
|
||||
@@ -147,7 +176,10 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise<voi
|
||||
end_km: Number(body.end_km),
|
||||
route_from: String(body.route_from),
|
||||
route_to: String(body.route_to),
|
||||
is_business: body.is_business === true || body.is_business === 1 || body.is_business === '1',
|
||||
is_business:
|
||||
body.is_business === true ||
|
||||
body.is_business === 1 ||
|
||||
body.is_business === "1",
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
},
|
||||
});
|
||||
@@ -158,84 +190,130 @@ export default async function tripsRoutes(fastify: FastifyInstance): Promise<voi
|
||||
data: { actual_km: Number(body.end_km) },
|
||||
});
|
||||
|
||||
await logAudit({ request, authData, action: 'create', entityType: 'trip', entityId: trip.id, description: `Vytvořena jízda` });
|
||||
return success(reply, { id: trip.id }, 201, 'Jízda byla zaznamenána');
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "create",
|
||||
entityType: "trip",
|
||||
entityId: trip.id,
|
||||
description: `Vytvořena jízda`,
|
||||
});
|
||||
return success(reply, { id: trip.id }, 201, "Jízda byla zaznamenána");
|
||||
});
|
||||
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
if (isNaN(id)) return error(reply, 'Neplatné ID', 400);
|
||||
const parsed = parseBody(UpdateTripSchema, 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: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
if (isNaN(id)) return error(reply, "Neplatné ID", 400);
|
||||
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 } });
|
||||
if (!existing) return error(reply, 'Jízda nenalezena', 404);
|
||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Jízda nenalezena", 404);
|
||||
|
||||
// Ownership check — same as DELETE handler
|
||||
const isAdmin = authData.permissions.includes('trips.admin');
|
||||
if (existing.user_id !== authData.userId && !isAdmin) {
|
||||
return error(reply, 'Nemáte oprávnění upravit tuto jízdu', 403);
|
||||
}
|
||||
// Ownership check — same as DELETE handler
|
||||
const isAdmin = authData.permissions.includes("trips.admin");
|
||||
if (existing.user_id !== authData.userId && !isAdmin) {
|
||||
return error(reply, "Nemáte oprávnění upravit tuto jízdu", 403);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.trip_date !== undefined) data.trip_date = new Date(String(body.trip_date));
|
||||
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
|
||||
if (body.end_km !== undefined) data.end_km = Number(body.end_km);
|
||||
if (body.route_from !== undefined) data.route_from = String(body.route_from);
|
||||
if (body.route_to !== undefined) data.route_to = String(body.route_to);
|
||||
if (body.is_business !== undefined) data.is_business = body.is_business === true || body.is_business === 1 || body.is_business === '1';
|
||||
if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null;
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.trip_date !== undefined)
|
||||
data.trip_date = new Date(String(body.trip_date));
|
||||
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
|
||||
if (body.end_km !== undefined) data.end_km = Number(body.end_km);
|
||||
if (body.route_from !== undefined)
|
||||
data.route_from = String(body.route_from);
|
||||
if (body.route_to !== undefined) data.route_to = String(body.route_to);
|
||||
if (body.is_business !== undefined)
|
||||
data.is_business =
|
||||
body.is_business === true ||
|
||||
body.is_business === 1 ||
|
||||
body.is_business === "1";
|
||||
if (body.notes !== undefined)
|
||||
data.notes = body.notes ? String(body.notes) : null;
|
||||
|
||||
await prisma.trips.update({ where: { id }, data });
|
||||
await prisma.trips.update({ where: { id }, data });
|
||||
|
||||
// Update vehicle actual_km if end_km changed
|
||||
if (body.end_km !== undefined) {
|
||||
const vehicleId = existing.vehicle_id;
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
if (maxTrip) {
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: { actual_km: Number(maxTrip.end_km) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "trip",
|
||||
entityId: id,
|
||||
description: `Upravena jízda`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Záznam byl aktualizován");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
if (isNaN(id)) return error(reply, "Neplatné ID", 400);
|
||||
const authData = request.authData!;
|
||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Jízda nenalezena", 404);
|
||||
|
||||
// Allow users to delete their own trips, admins can delete any
|
||||
const isAdmin = authData.permissions.includes("trips.admin");
|
||||
if (existing.user_id !== authData.userId && !isAdmin) {
|
||||
return error(reply, "Nemáte oprávnění smazat tuto jízdu", 403);
|
||||
}
|
||||
|
||||
// Update vehicle actual_km if end_km changed
|
||||
if (body.end_km !== undefined) {
|
||||
const vehicleId = existing.vehicle_id;
|
||||
await prisma.trips.delete({ where: { id } });
|
||||
|
||||
// Recalculate vehicle actual_km after deletion
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: 'desc' },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
if (maxTrip) {
|
||||
await prisma.vehicles.update({ where: { id: vehicleId }, data: { actual_km: Number(maxTrip.end_km) } });
|
||||
}
|
||||
}
|
||||
const vehicle = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
});
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: {
|
||||
actual_km: maxTrip
|
||||
? Number(maxTrip.end_km)
|
||||
: (vehicle?.initial_km ?? 0),
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData, action: 'update', entityType: 'trip', entityId: id, description: `Upravena jízda` });
|
||||
return success(reply, { id }, 200, 'Záznam byl aktualizován');
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
if (isNaN(id)) return error(reply, 'Neplatné ID', 400);
|
||||
const authData = request.authData!;
|
||||
const existing = await prisma.trips.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Jízda nenalezena', 404);
|
||||
|
||||
// Allow users to delete their own trips, admins can delete any
|
||||
const isAdmin = authData.permissions.includes('trips.admin');
|
||||
if (existing.user_id !== authData.userId && !isAdmin) {
|
||||
return error(reply, 'Nemáte oprávnění smazat tuto jízdu', 403);
|
||||
}
|
||||
|
||||
const vehicleId = existing.vehicle_id;
|
||||
await prisma.trips.delete({ where: { id } });
|
||||
|
||||
// Recalculate vehicle actual_km after deletion
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: 'desc' },
|
||||
select: { end_km: true },
|
||||
});
|
||||
const vehicle = await prisma.vehicles.findUnique({ where: { id: vehicleId }, select: { initial_km: true } });
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: { actual_km: maxTrip ? Number(maxTrip.end_km) : (vehicle?.initial_km ?? 0) },
|
||||
});
|
||||
|
||||
await logAudit({ request, authData, action: 'delete', entityType: 'trip', entityId: id, description: `Smazána jízda` });
|
||||
return success(reply, { id }, 200, 'Záznam byl smazán');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "delete",
|
||||
entityType: "trip",
|
||||
entityId: id,
|
||||
description: `Smazána jízda`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Záznam byl smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,108 +1,148 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
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 { CreateUserSchema, UpdateUserSchema } from '../../schemas/users.schema';
|
||||
import { listUsers, getUser, createUser, updateUser, deleteUser } from '../../services/users.service';
|
||||
import { FastifyInstance } from "fastify";
|
||||
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 { CreateUserSchema, UpdateUserSchema } from "../../schemas/users.schema";
|
||||
import {
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
} from "../../services/users.service";
|
||||
|
||||
export default async function usersRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
export default async function usersRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// GET /api/admin/users
|
||||
fastify.get('/', { preHandler: requirePermission('users.view') }, async (request, reply) => {
|
||||
const params = parsePagination(request.query as Record<string, unknown>);
|
||||
const result = await listUsers(params);
|
||||
fastify.get(
|
||||
"/",
|
||||
{ preHandler: requirePermission("users.view") },
|
||||
async (request, reply) => {
|
||||
const params = parsePagination(request.query as Record<string, unknown>);
|
||||
const result = await listUsers(params);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.users,
|
||||
pagination: buildPaginationMeta(result.total, result.page, result.limit),
|
||||
});
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.users,
|
||||
pagination: buildPaginationMeta(
|
||||
result.total,
|
||||
result.page,
|
||||
result.limit,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/users/:id
|
||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('users.view') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("users.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const user = await getUser(id);
|
||||
if (!user) return error(reply, 'Uživatel nenalezen', 404);
|
||||
return success(reply, user);
|
||||
});
|
||||
const user = await getUser(id);
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
return success(reply, user);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/users
|
||||
fastify.post('/', { preHandler: requirePermission('users.create') }, async (request, reply) => {
|
||||
const parsed = parseBody(CreateUserSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("users.create") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(CreateUserSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const result = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
is_active: body.is_active,
|
||||
});
|
||||
const result = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
is_active: body.is_active,
|
||||
});
|
||||
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'create',
|
||||
entityType: 'user',
|
||||
entityId: result.user.id,
|
||||
description: `Vytvořen uživatel ${result.user.username}`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "user",
|
||||
entityId: result.user.id,
|
||||
description: `Vytvořen uživatel ${result.user.username}`,
|
||||
});
|
||||
|
||||
return success(reply, { id: result.user.id }, 201, 'Uživatel byl vytvořen');
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{ id: result.user.id },
|
||||
201,
|
||||
"Uživatel byl vytvořen",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/admin/users/:id
|
||||
fastify.put<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('users.edit') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateUserSchema, request.body);
|
||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("users.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateUserSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
|
||||
const userData = {
|
||||
...parsed.data,
|
||||
role_id: parsed.data.role_id != null ? Number(parsed.data.role_id) : parsed.data.role_id as number | null | undefined,
|
||||
};
|
||||
const result = await updateUser(id, userData);
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
const userData = {
|
||||
...parsed.data,
|
||||
role_id:
|
||||
parsed.data.role_id != null
|
||||
? Number(parsed.data.role_id)
|
||||
: (parsed.data.role_id as number | null | undefined),
|
||||
};
|
||||
const result = await updateUser(id, userData);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
description: `Upraven uživatel ${result.username}`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "user",
|
||||
entityId: id,
|
||||
description: `Upraven uživatel ${result.username}`,
|
||||
});
|
||||
|
||||
return success(reply, { id }, 200, 'Uživatel byl uložen');
|
||||
});
|
||||
return success(reply, { id }, 200, "Uživatel byl uložen");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/users/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('users.delete') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("users.delete") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
const result = await deleteUser(id, request.authData?.userId);
|
||||
if ('error' in result) return error(reply, result.error!, result.status!);
|
||||
const result = await deleteUser(id, request.authData?.userId);
|
||||
if ("error" in result) return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: 'delete',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
description: `Smazán uživatel ${result.username}`,
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "user",
|
||||
entityId: id,
|
||||
description: `Smazán uživatel ${result.username}`,
|
||||
});
|
||||
|
||||
return success(reply, null, 200, 'Uživatel smazán');
|
||||
});
|
||||
return success(reply, null, 200, "Uživatel smazán");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import prisma from '../../config/database';
|
||||
import { requireAuth, 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';
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../../config/database";
|
||||
import { requireAuth, 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<void> {
|
||||
fastify.get('/', { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const vehicles = await prisma.vehicles.findMany({ orderBy: { name: 'asc' } });
|
||||
export default async function vehiclesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const vehicles = await prisma.vehicles.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
// Compute current_km and trip_count from trips table
|
||||
const tripStats = await prisma.trips.groupBy({
|
||||
by: ['vehicle_id'],
|
||||
by: ["vehicle_id"],
|
||||
_max: { end_km: true },
|
||||
_count: { id: true },
|
||||
});
|
||||
const statsMap = new Map(tripStats.map(s => [s.vehicle_id, { maxKm: s._max.end_km ?? 0, count: s._count.id }]));
|
||||
const statsMap = new Map(
|
||||
tripStats.map((s) => [
|
||||
s.vehicle_id,
|
||||
{ maxKm: s._max.end_km ?? 0, count: s._count.id },
|
||||
]),
|
||||
);
|
||||
|
||||
const enriched = vehicles.map(v => {
|
||||
const enriched = vehicles.map((v) => {
|
||||
const stats = statsMap.get(v.id);
|
||||
return {
|
||||
...v,
|
||||
@@ -30,59 +42,109 @@ export default async function vehiclesRoutes(fastify: FastifyInstance): Promise<
|
||||
return success(reply, enriched);
|
||||
});
|
||||
|
||||
fastify.post('/', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => {
|
||||
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: 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,
|
||||
},
|
||||
});
|
||||
fastify.post(
|
||||
"/",
|
||||
{ preHandler: requirePermission("trips.vehicles") },
|
||||
async (request, reply) => {
|
||||
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: 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,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'vehicle', entityId: vehicle.id, description: `Vytvořeno vozidlo ${vehicle.name}` });
|
||||
return success(reply, { id: vehicle.id }, 201, 'Vozidlo bylo vytvořeno');
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "vehicle",
|
||||
entityId: vehicle.id,
|
||||
description: `Vytvořeno vozidlo ${vehicle.name}`,
|
||||
});
|
||||
return success(reply, { id: vehicle.id }, 201, "Vozidlo bylo vytvořeno");
|
||||
},
|
||||
);
|
||||
|
||||
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 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);
|
||||
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 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);
|
||||
|
||||
await prisma.vehicles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
spz: body.spz !== undefined ? String(body.spz) : undefined,
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
brand: body.brand !== undefined ? (body.brand ? String(body.brand) : null) : undefined,
|
||||
model: body.model !== undefined ? (body.model ? String(body.model) : null) : undefined,
|
||||
initial_km: body.initial_km !== undefined ? Number(body.initial_km) : undefined,
|
||||
actual_km: body.actual_km !== undefined ? Number(body.actual_km) : undefined,
|
||||
is_active: body.is_active !== undefined ? (body.is_active === true || body.is_active === 1 || body.is_active === '1') : undefined,
|
||||
},
|
||||
});
|
||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'vehicle', entityId: id, description: `Upraveno vozidlo ${existing.name}` });
|
||||
return success(reply, { id }, 200, 'Vozidlo bylo uloženo');
|
||||
});
|
||||
await prisma.vehicles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
spz: body.spz !== undefined ? String(body.spz) : undefined,
|
||||
name: body.name !== undefined ? String(body.name) : undefined,
|
||||
brand:
|
||||
body.brand !== undefined
|
||||
? body.brand
|
||||
? String(body.brand)
|
||||
: null
|
||||
: undefined,
|
||||
model:
|
||||
body.model !== undefined
|
||||
? body.model
|
||||
? String(body.model)
|
||||
: null
|
||||
: undefined,
|
||||
initial_km:
|
||||
body.initial_km !== undefined ? Number(body.initial_km) : undefined,
|
||||
actual_km:
|
||||
body.actual_km !== undefined ? Number(body.actual_km) : undefined,
|
||||
is_active:
|
||||
body.is_active !== undefined
|
||||
? body.is_active === true ||
|
||||
body.is_active === 1 ||
|
||||
body.is_active === "1"
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "vehicle",
|
||||
entityId: id,
|
||||
description: `Upraveno vozidlo ${existing.name}`,
|
||||
});
|
||||
return success(reply, { id }, 200, "Vozidlo bylo uloženo");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('trips.vehicles') }, async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.vehicles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, 'Vozidlo nenalezeno', 404);
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("trips.vehicles") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const existing = await prisma.vehicles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Vozidlo nenalezeno", 404);
|
||||
|
||||
await prisma.vehicles.delete({ where: { id } });
|
||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'vehicle', entityId: id, description: `Smazáno vozidlo ${existing.name}` });
|
||||
return success(reply, null, 200, 'Vozidlo smazáno');
|
||||
});
|
||||
await prisma.vehicles.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "delete",
|
||||
entityType: "vehicle",
|
||||
entityId: id,
|
||||
description: `Smazáno vozidlo ${existing.name}`,
|
||||
});
|
||||
return success(reply, null, 200, "Vozidlo smazáno");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user