Files
app/src/routes/admin/company-settings.ts
2026-03-27 10:25:40 +01:00

445 lines
14 KiB
TypeScript

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<void> {
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<string, string>;
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";
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<string, string>;
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(),
path: config.nas.path || "—",
},
financials: {
configured: nasFinancialsManager.isConfigured(),
path: config.nas.financialsPath || "—",
},
offers: {
configured: nasOffersManager.isConfigured(),
path: config.nas.offersPath || "—",
},
},
});
},
);
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<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",
"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<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;
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");
},
);
}