feat: system settings, dynamic logos, template numbering, permission consolidation
- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,13 @@ 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/company-settings.schema";
|
||||
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(
|
||||
@@ -53,27 +59,37 @@ export default async function companySettingsRoutes(
|
||||
): Promise<void> {
|
||||
await fastify.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
|
||||
// GET /api/admin/company-settings/logo
|
||||
fastify.get("/logo", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { logo_data: true },
|
||||
});
|
||||
if (!settings?.logo_data) return error(reply, "Logo nenalezeno", 404);
|
||||
// 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 Buffer | null;
|
||||
if (!buf) return error(reply, "Logo nenalezeno", 404);
|
||||
|
||||
// Detect image type from magic bytes
|
||||
const buf = settings.logo_data;
|
||||
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).send(buf);
|
||||
return reply
|
||||
.type(mime)
|
||||
.header("Cache-Control", "public, max-age=3600")
|
||||
.send(buf);
|
||||
});
|
||||
|
||||
// POST /api/admin/company-settings/logo
|
||||
// POST /api/admin/company-settings/logo?variant=light|dark
|
||||
fastify.post(
|
||||
"/logo",
|
||||
{ preHandler: requirePermission("offers.settings") },
|
||||
{ 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);
|
||||
|
||||
@@ -92,7 +108,7 @@ export default async function companySettingsRoutes(
|
||||
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logo_data: new Uint8Array(buffer), modified_at: new Date() },
|
||||
data: { [column]: new Uint8Array(buffer), modified_at: new Date() },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
@@ -101,7 +117,7 @@ export default async function companySettingsRoutes(
|
||||
action: "update",
|
||||
entityType: "company_settings",
|
||||
entityId: existing.id,
|
||||
description: "Nahráno logo",
|
||||
description: `Nahráno logo (${variant})`,
|
||||
});
|
||||
return success(reply, null, 200, "Logo nahráno");
|
||||
},
|
||||
@@ -129,6 +145,22 @@ export default async function companySettingsRoutes(
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,6 +192,17 @@ export default async function companySettingsRoutes(
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -167,25 +210,134 @@ export default async function companySettingsRoutes(
|
||||
// Check if logo exists
|
||||
const logoCheck = await prisma.company_settings.findFirst({
|
||||
where: { id: settings.id },
|
||||
select: { logo_data: true },
|
||||
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,
|
||||
);
|
||||
|
||||
const pkg = await import("../../../package.json", {
|
||||
assert: { type: "json" },
|
||||
});
|
||||
|
||||
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.default.version,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/admin/company-settings/system-info
|
||||
fastify.get(
|
||||
"/system-info",
|
||||
{ preHandler: requirePermission("settings.manage") },
|
||||
async (request, reply) => {
|
||||
const pkg = await import("../../../package.json", {
|
||||
assert: { type: "json" },
|
||||
});
|
||||
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.default.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("offers.settings") },
|
||||
{ preHandler: requirePermission("settings.manage") },
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
@@ -207,6 +359,13 @@ export default async function companySettingsRoutes(
|
||||
"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) {
|
||||
@@ -216,6 +375,24 @@ export default async function companySettingsRoutes(
|
||||
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
|
||||
@@ -247,6 +424,8 @@ export default async function companySettingsRoutes(
|
||||
data,
|
||||
});
|
||||
|
||||
invalidateSettingsCache();
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
|
||||
Reference in New Issue
Block a user