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, createOffer, updateOffer, deleteOffer, duplicateOffer, invalidateOffer, getNextOfferNumber, } from "../../services/offers.service"; import { nasOffersManager } from "../../services/nas-offers-manager"; const LOCK_TIMEOUT_MS = 10 * 1000; // 10 seconds — lock expires if no heartbeat export default async function quotationsRoutes( fastify: FastifyInstance, ): Promise { fastify.get( "/", { preHandler: requirePermission("offers.view") }, async (request, reply) => { const query = request.query as Record; 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, }); 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 }); }, ); // 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; 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", ); }, ); // 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; 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"); }, ); 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 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 }); }, ); // 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; 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, ); } } await prisma.quotations.update({ where: { id }, data: { locked_by: request.authData!.userId, locked_at: new Date() }, }); 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; await prisma.quotations.updateMany({ where: { id, locked_by: request.authData!.userId }, data: { locked_at: new Date() }, }); 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; await prisma.quotations.updateMany({ where: { id, locked_by: request.authData!.userId }, data: { locked_by: null, locked_at: 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); 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", ); }, ); 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); return error(reply, "Neznámá chyba", 500); } // 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.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; const existing = await deleteOffer(id); if (!existing) return error(reply, "Nabídka nenalezena", 404); // Delete PDF from NAS if (existing.quotation_number && existing.created_at) { const yr = new Date(existing.created_at).getFullYear(); nasOffersManager.deleteOfferPdf( nasOffersManager.buildRelativePath(existing.quotation_number, yr), ); } 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"); }, ); // GET /api/admin/offers/:id/file — serve PDF from NAS fastify.get<{ Params: { id: string } }>( "/:id/file", { preHandler: requirePermission("offers.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const offer = await prisma.quotations.findUnique({ where: { id }, select: { quotation_number: true, created_at: true }, }); if (!offer?.quotation_number) return error(reply, "Nabídka nenalezena", 404); const year = offer.created_at ? new Date(offer.created_at).getFullYear() : new Date().getFullYear(); const relPath = nasOffersManager.buildRelativePath( offer.quotation_number, year, ); const file = nasOffersManager.readOfferPdf(relPath); if (!file) return error(reply, "PDF soubor nenalezen", 404); return reply .type("application/pdf") .header( "Content-Disposition", `inline; filename="${offer.quotation_number}.pdf"`, ) .send(file.data); }, ); }