import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; 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 { parseBody } from "../../schemas/common"; import { CreateCustomerSchema, UpdateCustomerSchema, } from "../../schemas/customers.schema"; import { encodeCustomFields, decodeCustomFields as decodeCustomFieldsShared, } from "../../utils/custom-fields"; const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"]; /** Customer-shaped wrapper over the shared codec (field_order key naming). */ function decodeCustomFields(raw: string | null): { custom_fields: unknown[]; customer_field_order: string[]; } { const { custom_fields, field_order } = decodeCustomFieldsShared(raw); return { custom_fields, customer_field_order: field_order }; } export default async function customersRoutes( fastify: FastifyInstance, ): Promise { fastify.get( "/", { preHandler: requirePermission("customers.view") }, async (request, reply) => { const { page, limit, skip, sort, order, search } = parsePagination( request.query as Record, ); const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "name"; const where = search ? { OR: [ { name: { contains: search } }, { company_id: { contains: search } }, ], } : {}; const [customers, total] = await Promise.all([ prisma.customers.findMany({ where, skip, take: limit, // {id} tiebreak: the allow-listed sort columns are non-unique, and // offset pagination over a tied run reorders between page queries // (rows duplicate/vanish across pages) without a total order. orderBy: [{ [sortField]: order }, { id: 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, }; }); return paginated( reply, enriched, buildPaginationMeta(total, page, limit), ); }, ); fastify.get<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("customers.view") }, 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.create") }, 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, ), }, }); 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.edit") }, 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; 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.delete") }, 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"); }, ); }