445 lines
14 KiB
TypeScript
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");
|
|
},
|
|
);
|
|
}
|