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/settings.schema"; import { invalidateSettingsCache } from "../../services/system-settings"; import os from "os"; import { config } from "../../config/env"; import { NasFileManager } from "../../services/nas-file-manager"; import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { nasOffersManager } from "../../services/nas-offers-manager"; /** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */ 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; return JSON.stringify({ fields: f, field_order: o }); } /** Decode custom_fields JSON blob into separate fields + field_order for frontend */ 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 || [], }; } // Legacy TS format: raw array if (Array.isArray(parsed)) { return { custom_fields: parsed, supplier_field_order: [] }; } return { custom_fields: [], supplier_field_order: [] }; } catch { return { custom_fields: [], supplier_field_order: [] }; } } export default async function companySettingsRoutes( fastify: FastifyInstance, ): Promise { await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); // GET /api/admin/company-settings/logo?variant=light|dark fastify.get("/logo", { preHandler: requireAuth }, async (request, reply) => { const query = request.query as Record; const variant = query.variant === "dark" ? "dark" : "light"; const column = variant === "dark" ? "logo_data_dark" : "logo_data"; const settings = await prisma.company_settings.findFirst({ select: { [column]: true }, }); const buf = settings?.[column] as unknown as Buffer | null; if (!buf) return error(reply, "Logo nenalezeno", 404); 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 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 ) mime = "image/webp"; return reply .type(mime) .header("Cache-Control", "public, max-age=3600") .send(buf); }); // POST /api/admin/company-settings/logo?variant=light|dark fastify.post( "/logo", { preHandler: requirePermission("settings.manage") }, async (request, reply) => { const query = request.query as Record; const variant = query.variant === "dark" ? "dark" : "light"; const column = variant === "dark" ? "logo_data_dark" : "logo_data"; 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 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: { [column]: 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 (${variant})`, }); return success(reply, null, 200, "Logo nahráno"); }, ); fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => { let settings = await prisma.company_settings.findFirst({ select: { id: true, company_name: true, street: true, city: true, postal_code: true, country: true, company_id: true, vat_id: true, custom_fields: true, quotation_prefix: true, default_currency: true, default_vat_rate: true, uuid: true, modified_at: true, is_deleted: true, sync_version: true, order_type_code: true, invoice_type_code: true, require_2fa: true, break_threshold_hours: true, break_duration_short: true, break_duration_long: true, clock_rounding_minutes: true, invoice_alert_email: true, leave_notify_email: true, max_login_attempts: true, lockout_minutes: true, max_requests_per_minute: true, available_vat_rates: true, available_currencies: true, smtp_from: true, smtp_from_name: true, offer_number_pattern: true, order_number_pattern: true, invoice_number_pattern: true, }, }); if (!settings) { settings = await prisma.company_settings.create({ data: { company_name: "", quotation_prefix: "N", default_currency: "EUR", default_vat_rate: 21.0, }, select: { id: true, company_name: true, street: true, city: true, postal_code: true, country: true, company_id: true, vat_id: true, custom_fields: true, quotation_prefix: true, default_currency: true, default_vat_rate: true, uuid: true, modified_at: true, is_deleted: true, sync_version: true, order_type_code: true, invoice_type_code: true, require_2fa: true, break_threshold_hours: true, break_duration_short: true, break_duration_long: true, clock_rounding_minutes: true, invoice_alert_email: true, leave_notify_email: true, max_login_attempts: true, lockout_minutes: true, max_requests_per_minute: true, available_vat_rates: true, available_currencies: true, smtp_from: true, smtp_from_name: true, offer_number_pattern: true, order_number_pattern: true, invoice_number_pattern: true, }, }); } if (!settings) return error(reply, "Nastavení nenalezeno", 500); // Check if logo exists const logoCheck = await prisma.company_settings.findFirst({ where: { id: settings.id }, select: { logo_data: true, logo_data_dark: true }, }); const has_logo = !!logoCheck?.logo_data; const has_logo_dark = !!logoCheck?.logo_data_dark; const { custom_fields, supplier_field_order } = decodeCustomFields( settings.custom_fields as string | null, ); // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require("../../../package.json") as { version: string }; let available_vat_rates: number[] = [0, 10, 12, 15, 21]; try { const raw = settings.available_vat_rates as string | null; if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) available_vat_rates = parsed; } } catch { /* ignore */ } let available_currencies: string[] = ["CZK", "EUR", "USD", "GBP"]; try { const raw = settings.available_currencies as string | null; if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.length > 0) available_currencies = parsed; } } catch { /* ignore */ } return success(reply, { ...settings, custom_fields, supplier_field_order, available_vat_rates, available_currencies, has_logo, has_logo_dark, app_version: pkg.version, }); }); // GET /api/admin/company-settings/system-info fastify.get( "/system-info", { preHandler: requirePermission("settings.manage") }, async (request, reply) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require("../../../package.json") as { version: string }; const uptimeSec = process.uptime(); const days = Math.floor(uptimeSec / 86400); const hours = Math.floor((uptimeSec % 86400) / 3600); const mins = Math.floor((uptimeSec % 3600) / 60); const uptimeStr = days > 0 ? `${days}d ${hours}h ${mins}m` : hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; const mem = process.memoryUsage(); const totalMem = os.totalmem(); const freeMem = os.freemem(); // DB connection check let dbStatus = "ok"; let migrationCount = 0; try { const result = await prisma.$queryRaw<[{ cnt: bigint }]>` SELECT COUNT(*) as cnt FROM _prisma_migrations WHERE finished_at IS NOT NULL `; migrationCount = Number(result[0]?.cnt ?? 0); } catch (err) { dbStatus = "error"; request.log.error(err, "DB health check failed"); } // NAS status const projectNas = new NasFileManager(); return success(reply, { app_version: pkg.version, node_version: process.version, platform: `${os.type()} ${os.release()}`, uptime: uptimeStr, environment: config.appEnv, timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone, memory: { rss: `${Math.round(mem.rss / 1024 / 1024)} MB`, heap_used: `${Math.round(mem.heapUsed / 1024 / 1024)} MB`, heap_total: `${Math.round(mem.heapTotal / 1024 / 1024)} MB`, system_total: `${Math.round(totalMem / 1024 / 1024)} MB`, system_free: `${Math.round(freeMem / 1024 / 1024)} MB`, }, database: { status: dbStatus, migrations_applied: migrationCount, }, nas: { projects: { configured: projectNas.isConfigured(), }, financials: { configured: nasFinancialsManager.isConfigured(), }, offers: { configured: nasOffersManager.isConfigured(), }, }, }); }, ); fastify.put( "/", { preHandler: requirePermission("settings.manage") }, 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 data: Record = { 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", "invoice_alert_email", "leave_notify_email", "smtp_from", "smtp_from_name", "offer_number_pattern", "order_number_pattern", "invoice_number_pattern", ]; const bodyRec = body as Record; 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; const numFields = [ "break_threshold_hours", "break_duration_short", "break_duration_long", "clock_rounding_minutes", "max_login_attempts", "lockout_minutes", "max_requests_per_minute", ] as const; for (const f of numFields) { if (bodyRec[f] !== undefined) data[f] = Number(bodyRec[f]); } if (body.available_vat_rates !== undefined) data.available_vat_rates = JSON.stringify(body.available_vat_rates); if (body.available_currencies !== undefined) data.available_currencies = JSON.stringify(body.available_currencies); 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, }); invalidateSettingsCache(); 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"); }, ); }