import { FastifyInstance } from "fastify"; import { requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId, paginated } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { contentDisposition } from "../../utils/content-disposition"; import { parseBody } from "../../schemas/common"; import { CreateQuotationSchema, UpdateQuotationSchema, } from "../../schemas/offers.schema"; import prisma from "../../config/database"; import { listOffers, getOfferTotals, getOffer, createOffer, updateOffer, deleteOffer, duplicateOffer, invalidateOffer, getNextOfferNumber, } from "../../services/offers.service"; import { nasOffersManager } from "../../services/nas-offers-manager"; import { renderOfferHtml } from "./offers-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf"; // 3× the client's 10s heartbeat: a single missed beat (background-tab // throttling, transient network) must not silently hand the lock to a second // editor. Keep in sync with issued-orders.ts and useDocumentLock. const LOCK_TIMEOUT_MS = 30 * 1000; 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 paginated( reply, result.data, buildPaginationMeta(result.total, page, limit), ); }, ); // GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE // filtered set (not one page). Offers are not tax documents — no VAT // anywhere. Offers have NO month/year filter; the list filters by // search + status + customer_id, so the totals use the same. // Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id. fastify.get( "/stats", { preHandler: requirePermission("offers.view") }, async (request, reply) => { const query = request.query as Record; const result = await getOfferTotals({ search: query.search ? String(query.search) : undefined, status: query.status ? String(query.status) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined, }); return success(reply, { totals: result.totals }); }, ); // 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 result = await invalidateOffer(id); if ("error" in result) { if (result.error === "not_found") return error(reply, "Nabídka nenalezena", 404); if (result.error === "invalid_transition") return error( reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400, ); return error(reply, "Neznámá chyba", 500); } const existing = result.data; await logAudit({ request, authData: request.authData, action: "update", entityType: "quotation", entityId: id, description: `Zneplatněna nabídka ${existing.quotation_number ?? "koncept"}`, oldValues: { status: existing.status }, newValues: { status: "invalidated" }, }); 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); // `getOffer` already loaded the quotation row, including locked_by / // locked_at — reuse them instead of a second findUnique. let lockedBy: { user_id: number; username: string; full_name: string; } | null = null; if (data.locked_by && data.locked_at) { const lockAge = Date.now() - new Date(data.locked_at).getTime(); if ( lockAge < LOCK_TIMEOUT_MS && data.locked_by !== request.authData!.userId ) { const lockUser = await prisma.users.findUnique({ where: { id: data.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); if ("error" in quotation) { if (quotation.error === "customer_not_found") return error(reply, "Zákazník nenalezen", 400); if (quotation.error === "number_taken") return error(reply, "Číslo nabídky je již použito", 409); return error(reply, "Neznámá chyba", 500); } await logAudit({ request, authData: request.authData, action: "create", entityType: "quotation", entityId: quotation.id, description: `Vytvořena nabídka ${quotation.quotation_number ?? "koncept"}`, }); 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 === "not_editable") return error(reply, "Nabídku v tomto stavu nelze upravovat", 400); if (result.error === "invalid_transition") return error( reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400, ); if (result.error === "customer_not_found") return error(reply, "Zákazník nenalezen", 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 ?? "koncept"}`, oldValues: result.oldValues, newValues: result.newValues, }); 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 result = await deleteOffer(id); if ("error" in result) { if (result.error === "not_found") return error(reply, "Nabídka nenalezena", 404); if (result.error === "linked") return error( reply, "Nabídku nelze smazat, je navázána na objednávku nebo projekt", 409, ); return error(reply, "Neznámá chyba", 500); } const existing = result.data; // NAS PDF cleanup is owned by deleteOffer (single owner) — the route // used to repeat it here, which always failed the second time and logged // a spurious error. await logAudit({ request, authData: request.authData, action: "delete", entityType: "quotation", entityId: id, description: `Smazána nabídka ${existing.quotation_number ?? "koncept"}`, oldValues: existing as unknown as Record, }); 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 reply .type("application/pdf") .header( "Content-Disposition", // Quotation numbers contain "/" segments — not header- or // filename-safe raw; the helper handles non-ASCII per RFC 5987. contentDisposition( "inline", `Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`, ), ) .send(file.data); } // Archive miss → live-render fallback (same model as issued orders' // /:id/file): rebuild the PDF from current data with the exact PDF-route // template, re-archive it so the next request is a plain hit, and serve // the fresh bytes with identical response semantics. try { const quotation = await prisma.quotations.findUnique({ where: { id }, include: { customers: true, quotation_items: { orderBy: { position: "asc" } }, scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] }, }, }); if (!quotation) return error(reply, "Nabídka nenalezena", 404); const settings = await prisma.company_settings.findFirst(); const html = renderOfferHtml(quotation, settings); const pdfBuffer = await htmlToPdf(html); if (nasOffersManager.isConfigured()) { // saveOfferPdf logs internally and returns null on failure — // archiving is best-effort here, serving the PDF must not fail. const saved = nasOffersManager.saveOfferPdf( offer.quotation_number, year, pdfBuffer, ); if (!saved) { request.log.error( `Archivace PDF nabídky ${offer.quotation_number} na NAS při /file fallbacku selhala`, ); } } return reply .type("application/pdf") .header( "Content-Disposition", // Quotation numbers contain "/" segments — not header- or // filename-safe raw; the helper handles non-ASCII per RFC 5987. contentDisposition( "inline", `Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`, ), ) .send(pdfBuffer); } catch (err) { request.log.error(err, "Offer /file live-render fallback failed"); return error(reply, "Chyba při generování PDF", 500); } }, ); }