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:
BOHA
2026-03-27 10:15:47 +01:00
parent f49015a627
commit 6b31b2f74b
43 changed files with 2094 additions and 525 deletions

View File

@@ -14,7 +14,7 @@ export default async function bankAccountsRoutes(
): Promise<void> {
fastify.get(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({
orderBy: { position: "asc" },
@@ -25,7 +25,7 @@ export default async function bankAccountsRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const parsed = parseBody(CreateBankAccountSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -59,7 +59,7 @@ export default async function bankAccountsRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -126,7 +126,7 @@ export default async function bankAccountsRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -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,

View File

@@ -236,7 +236,7 @@ export default async function receivedInvoicesRoutes(
try {
invoicesMeta = JSON.parse(part.value as string);
} catch {
/* ignore parse error */
// Malformed invoices metadata — ignore, use defaults
}
}
}

View File

@@ -12,7 +12,7 @@ export default async function rolesRoutes(
// GET /api/admin/roles
fastify.get(
"/",
{ preHandler: requirePermission("settings.roles") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const roles = await prisma.roles.findMany({
include: {
@@ -35,7 +35,7 @@ export default async function rolesRoutes(
// GET /api/admin/roles/permissions
fastify.get(
"/permissions",
{ preHandler: requirePermission("settings.roles") },
{ preHandler: requirePermission("settings.manage") },
async (_request, reply) => {
const permissions = await prisma.permissions.findMany({
orderBy: { module: "asc" },
@@ -47,7 +47,7 @@ export default async function rolesRoutes(
// POST /api/admin/roles
fastify.post(
"/",
{ preHandler: requirePermission("settings.roles") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const parsed = parseBody(CreateRoleSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -86,7 +86,7 @@ export default async function rolesRoutes(
// PUT /api/admin/roles/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.roles") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -136,7 +136,7 @@ export default async function rolesRoutes(
// DELETE /api/admin/roles/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.roles") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -22,7 +22,7 @@ export default async function scopeTemplatesRoutes(
// Legacy ?action= dispatcher for item templates
fastify.get(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const action = query.action ? String(query.action) : null;
@@ -53,7 +53,7 @@ export default async function scopeTemplatesRoutes(
// Item template CRUD via ?action=item
fastify.post(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
@@ -121,7 +121,7 @@ export default async function scopeTemplatesRoutes(
// Item template delete via DELETE ?action=item&id=X
fastify.delete(
"/",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
@@ -140,7 +140,7 @@ export default async function scopeTemplatesRoutes(
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -161,7 +161,7 @@ export default async function scopeTemplatesRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -208,7 +208,7 @@ export default async function scopeTemplatesRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.settings") },
{ preHandler: requirePermission("settings.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -5,6 +5,7 @@ import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { success, error } from "../../utils/response";
import { encrypt } from "../../utils/encryption";
import { getSystemSettings } from "../../services/system-settings";
import { OTPAuth } from "../../utils/totp";
import * as OTPAuthLib from "otpauth";
import { logAudit } from "../../services/audit";
@@ -16,9 +17,18 @@ export default async function totpRoutes(
): Promise<void> {
// GET - generate new TOTP secret
fastify.get("/setup", { preHandler: requireAuth }, async (request, reply) => {
const settings = await getSystemSettings();
const companyName =
(
await prisma.company_settings.findFirst({
select: { company_name: true },
})
)?.company_name ||
settings.smtp_from_name ||
"System";
const secret = new OTPAuthLib.Secret();
const totp = new OTPAuthLib.TOTP({
issuer: "BOHA Automation",
issuer: companyName,
label: request.authData!.email,
secret,
algorithm: "SHA1",
@@ -153,7 +163,7 @@ export default async function totpRoutes(
// GET - check if 2FA is required company-wide
fastify.get(
"/required",
{ preHandler: [requireAuth, requirePermission("settings.security")] },
{ preHandler: [requireAuth, requirePermission("settings.manage")] },
async (request, reply) => {
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
@@ -167,7 +177,7 @@ export default async function totpRoutes(
fastify.post(
"/required",
{
preHandler: [requireAuth, requirePermission("settings.security")],
preHandler: [requireAuth, requirePermission("settings.manage")],
bodyLimit: 10240,
},
async (request, reply) => {