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

@@ -2,6 +2,7 @@ import { attendance_leave_type, Prisma } from "@prisma/client";
import prisma from "../config/database";
import { getBusinessDaysInMonth } from "../utils/czech-holidays";
import { localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
type AttendanceWithRelations = Prisma.attendanceGetPayload<{
include: {
@@ -48,13 +49,13 @@ function calcWorkedHours(
return Math.max(0, mins) / 60;
}
const roundUp15 = (d: Date) => {
const ms = 15 * 60 * 1000;
const roundUp = (d: Date, minutes: number) => {
const ms = minutes * 60 * 1000;
return new Date(Math.ceil(d.getTime() / ms) * ms);
};
const roundDown15 = (d: Date) => {
const ms = 15 * 60 * 1000;
const roundDown = (d: Date, minutes: number) => {
const ms = minutes * 60 * 1000;
return new Date(Math.floor(d.getTime() / ms) * ms);
};
@@ -1189,6 +1190,7 @@ export async function createLeave(data: LeaveData, authUserId: number) {
}
export async function punchAction(userId: number, data: PunchData) {
const settings = await getSystemSettings();
const action = data.punch_action;
const now = new Date();
const y = now.getFullYear(),
@@ -1223,7 +1225,7 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Máte již aktivní směnu. Nejdříve zaznamenejte odchod." };
}
const arrivalTime = roundUp15(now);
const arrivalTime = roundUp(now, settings.clock_rounding_minutes);
const record = await prisma.attendance.create({
data: {
user_id: userId,
@@ -1257,7 +1259,7 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu." };
}
const departureTime = roundDown15(now);
const departureTime = roundDown(now, settings.clock_rounding_minutes);
const updateData: Record<string, unknown> = {
departure_time: departureTime,
@@ -1270,9 +1272,12 @@ export async function punchAction(userId: number, data: PunchData) {
if (!ongoing.break_start && ongoing.arrival_time) {
const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime();
const shiftHours = shiftMs / (1000 * 60 * 60);
if (shiftHours > 6) {
if (shiftHours > settings.break_threshold_hours) {
const midpoint = new Date(ongoing.arrival_time.getTime() + shiftMs / 2);
const breakMins = shiftHours > 12 ? 30 : 15;
const breakMins =
shiftHours > settings.break_threshold_hours * 2
? settings.break_duration_long
: settings.break_duration_short;
updateData.break_start = midpoint;
updateData.break_end = new Date(
midpoint.getTime() + breakMins * 60 * 1000,
@@ -1311,9 +1316,11 @@ export async function punchAction(userId: number, data: PunchData) {
return { error: "Nemáte aktivní směnu bez přestávky." };
}
const ms10 = 10 * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / ms10) * ms10);
const breakEnd = new Date(breakStart.getTime() + 30 * 60 * 1000);
const msRound = settings.clock_rounding_minutes * 60 * 1000;
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
const breakEnd = new Date(
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
);
await prisma.attendance.update({
where: { id: ongoing.id },

View File

@@ -5,6 +5,7 @@ import { FastifyRequest, FastifyReply } from "fastify";
import prisma from "../config/database";
import { config } from "../config/env";
import { AuthData, JwtPayload } from "../types";
import { getSystemSettings } from "./system-settings";
// Pre-computed bcrypt hash for timing-safe comparison when user not found
const DUMMY_HASH =
@@ -121,14 +122,15 @@ export async function login(
const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) {
const settings = await getSystemSettings();
const attempts = (user.failed_login_attempts ?? 0) + 1;
const updateData: Record<string, unknown> = {
failed_login_attempts: attempts,
};
if (attempts >= config.security.maxLoginAttempts) {
if (attempts >= settings.max_login_attempts) {
updateData.locked_until = new Date(
Date.now() + config.security.lockoutMinutes * 60_000,
Date.now() + settings.lockout_minutes * 60_000,
);
}

View File

@@ -2,6 +2,7 @@ import prisma from "../config/database";
import { config } from "../config/env";
import { sendMail } from "./mailer";
import { localDateCzStr, localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
interface AlertInvoice {
id: number;
@@ -31,7 +32,8 @@ function formatAmount(n: number | { toNumber?: () => number }): string {
}
export async function checkInvoiceAlerts(): Promise<void> {
const alertEmail = config.email.invoiceAlert;
const settings = await getSystemSettings();
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
if (!alertEmail) return;
const today = new Date();

View File

@@ -69,8 +69,8 @@ export async function markOverdueInvoices() {
where: { status: "issued", due_date: { lt: new Date() } },
data: { status: "overdue" },
});
} catch {
/* silent */
} catch (err) {
console.error("markOverdueInvoices failed:", err);
}
}
@@ -141,26 +141,7 @@ export async function listInvoices(params: ListInvoicesParams) {
return { data: enriched, total, page, limit };
}
export async function getNextInvoiceNumberFormatted() {
const settings = await prisma.company_settings.findFirst({
select: { invoice_type_code: true },
});
const typeCode = settings?.invoice_type_code || "81";
const yy = String(new Date().getFullYear()).slice(-2);
const prefix = `${yy}${typeCode}`;
const prefixLen = prefix.length;
const likePattern = `${prefix}%`;
// MAX from existing invoices — same approach as offers/orders
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_num
FROM invoices
WHERE invoice_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
const number = `${prefix}${String(nextNum).padStart(4, "0")}`;
return { number, next_number: number };
}
export { generateInvoiceNumber as getNextInvoiceNumberFormatted } from "./numbering.service";
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const now = new Date();

View File

@@ -1,6 +1,7 @@
import { sendMail } from "./mailer";
import { config } from "../config/env";
import { localDateCzStr, localDateTimeCzStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
const LEAVE_TYPE_LABELS: Record<string, string> = {
vacation: "Dovolená",
@@ -38,7 +39,8 @@ export async function notifyNewLeaveRequest(
request: LeaveRequestData,
employeeName: string,
): Promise<void> {
const notifyEmail = config.email.leaveNotify;
const settings = await getSystemSettings();
const notifyEmail = settings.leave_notify_email || config.email.leaveNotify;
if (!notifyEmail) return;
const leaveType = LEAVE_TYPE_LABELS[request.leave_type] || request.leave_type;

View File

@@ -1,5 +1,6 @@
import nodemailer from "nodemailer";
import { config } from "../config/env";
import { getSystemSettings } from "./system-settings";
const transporter = nodemailer.createTransport({
sendmail: true,
@@ -12,14 +13,18 @@ export async function sendMail(
subject: string,
html: string,
): Promise<boolean> {
const settings = await getSystemSettings();
const from =
settings.smtp_from ||
config.email.smtpFrom ||
config.email.contactFrom ||
"web@boha-automation.cz";
"noreply@example.com";
const fromName =
settings.smtp_from_name || config.email.smtpFromName || "System";
try {
await transporter.sendMail({
from: { name: config.email.smtpFromName, address: from },
from: { name: fromName, address: from },
to,
subject,
html,

View File

@@ -1,19 +1,122 @@
import prisma from "../config/database";
// Default patterns (backward compatible with existing numbers)
const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
/**
* Shared number generator for orders and projects.
* Format: YYtypeCode + 4-digit sequence (e.g., 26710003)
* Queries MAX from both orders and projects tables.
* Apply a numbering pattern template.
* Placeholders: {YYYY}, {YY}, {PREFIX}, {CODE}, {N+} (padding = count of N's)
*/
function applyPattern(
pattern: string,
vars: { year: number; prefix: string; code: string; seq: number },
): string {
const yyyy = String(vars.year);
const yy = yyyy.slice(-2);
return pattern.replace(/\{(\w+)\}/g, (match, key: string) => {
if (key === "YYYY") return yyyy;
if (key === "YY") return yy;
if (key === "PREFIX") return vars.prefix;
if (key === "CODE") return vars.code;
if (/^N+$/.test(key)) return String(vars.seq).padStart(key.length, "0");
return match;
});
}
/**
* Extract the static prefix and sequence position from a pattern.
* Used to build SQL LIKE patterns for MAX(seq) queries.
*/
function buildLikePattern(
pattern: string,
vars: { year: number; prefix: string; code: string },
): { likePattern: string; prefixLen: number } {
const yyyy = String(vars.year);
const yy = yyyy.slice(-2);
let staticPrefix = "";
let foundSeq = false;
const parts = pattern.split(/(\{[^}]+\})/);
for (const part of parts) {
const m = part.match(/^\{(\w+)\}$/);
if (!m) {
staticPrefix += part;
continue;
}
const key = m[1];
if (/^N+$/.test(key)) {
foundSeq = true;
break;
}
if (key === "YYYY") staticPrefix += yyyy;
else if (key === "YY") staticPrefix += yy;
else if (key === "PREFIX") staticPrefix += vars.prefix;
else if (key === "CODE") staticPrefix += vars.code;
}
if (!foundSeq) {
return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length };
}
return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length };
}
async function getSettings() {
return prisma.company_settings.findFirst({
select: {
quotation_prefix: true,
order_type_code: true,
invoice_type_code: true,
offer_number_pattern: true,
order_number_pattern: true,
invoice_number_pattern: true,
},
});
}
/**
* Next offer/quotation number.
*/
export async function generateOfferNumber(): Promise<string> {
const settings = await getSettings();
const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
const prefix = settings?.quotation_prefix || "NA";
const year = new Date().getFullYear();
const { likePattern, prefixLen } = buildLikePattern(pattern, {
year,
prefix,
code: "",
});
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING(quotation_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq
FROM quotations
WHERE quotation_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
return applyPattern(pattern, { year, prefix, code: "", seq: nextNum });
}
/**
* Shared number for orders and projects.
*/
export async function generateSharedNumber(): Promise<string> {
const settings = await prisma.company_settings.findFirst({
select: { order_type_code: true },
const settings = await getSettings();
const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN;
const code = settings?.order_type_code || "71";
const year = new Date().getFullYear();
const { likePattern, prefixLen } = buildLikePattern(pattern, {
year,
prefix: "",
code,
});
const typeCode = settings?.order_type_code || "71";
const yy = String(new Date().getFullYear()).slice(-2);
const prefix = `${yy}${typeCode}`;
const prefixLen = prefix.length;
const likePattern = `${prefix}%`;
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
@@ -25,51 +128,53 @@ export async function generateSharedNumber(): Promise<string> {
) combined
`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
return `${prefix}${String(nextNum).padStart(4, "0")}`;
return applyPattern(pattern, { year, prefix: "", code, seq: nextNum });
}
/**
* Next offer number. Queries MAX from quotations table.
* Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008)
* Next invoice number.
*/
export async function generateOfferNumber(): Promise<string> {
const settings = await prisma.company_settings.findFirst({
select: { quotation_prefix: true },
});
const prefix = settings?.quotation_prefix || "NA";
const year = new Date().getFullYear();
const likePattern = `${year}/${prefix}/%`;
export async function generateInvoiceNumber(
_year?: number,
): Promise<{ number: string; next_number: string }> {
const settings = await getSettings();
const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN;
const code = settings?.invoice_type_code || "81";
const year = _year || new Date().getFullYear();
const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
FROM quotations
WHERE quotation_number LIKE ${likePattern}
const { likePattern, prefixLen } = buildLikePattern(pattern, {
year,
prefix: "",
code,
});
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq
FROM invoices
WHERE invoice_number LIKE ${likePattern}
`;
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
return `${year}/${prefix}/${String(nextNum).padStart(3, "0")}`;
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
const number = applyPattern(pattern, {
year,
prefix: "",
code,
seq: nextNum,
});
return { number, next_number: number };
}
/**
* Next invoice number via atomic sequence table.
*/
export async function generateInvoiceNumber(year: number): Promise<number> {
return prisma.$transaction(async (tx) => {
const existing = await tx.number_sequences.findFirst({
where: { type: "invoice", year },
});
if (existing) {
const nextNum = (existing.last_number ?? 0) + 1;
await tx.number_sequences.update({
where: { id: existing.id },
data: { last_number: nextNum },
});
return nextNum;
}
await tx.number_sequences.create({
data: { type: "invoice", year, last_number: 1 },
});
return 1;
/** Preview what a pattern would produce (for settings UI) */
export function previewPattern(
pattern: string,
prefix: string,
code: string,
): string {
return applyPattern(pattern, {
year: new Date().getFullYear(),
prefix,
code,
seq: 1,
});
}

View File

@@ -0,0 +1,97 @@
import prisma from "../config/database";
interface SystemSettings {
break_threshold_hours: number;
break_duration_short: number;
break_duration_long: number;
clock_rounding_minutes: number;
invoice_alert_email: string;
leave_notify_email: string;
max_login_attempts: number;
lockout_minutes: number;
max_requests_per_minute: number;
default_currency: string;
default_vat_rate: number;
available_vat_rates: number[];
available_currencies: string[];
smtp_from: string;
smtp_from_name: string;
}
const DEFAULTS: SystemSettings = {
break_threshold_hours: 6,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
invoice_alert_email: "",
leave_notify_email: "",
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
default_currency: "CZK",
default_vat_rate: 21,
available_vat_rates: [0, 10, 12, 15, 21],
available_currencies: ["CZK", "EUR", "USD", "GBP"],
smtp_from: "",
smtp_from_name: "",
};
let cache: SystemSettings | null = null;
let cacheTime = 0;
const CACHE_TTL = 60_000; // 60 seconds
export async function getSystemSettings(): Promise<SystemSettings> {
if (cache && Date.now() - cacheTime < CACHE_TTL) return cache;
const row = await prisma.company_settings.findFirst();
if (!row) {
cache = { ...DEFAULTS };
cacheTime = Date.now();
return cache;
}
let vatRates = DEFAULTS.available_vat_rates;
let currencies = DEFAULTS.available_currencies;
try {
if (row.available_vat_rates) vatRates = JSON.parse(row.available_vat_rates);
} catch {
/* keep default */
}
try {
if (row.available_currencies)
currencies = JSON.parse(row.available_currencies);
} catch {
/* keep default */
}
cache = {
break_threshold_hours: Number(
row.break_threshold_hours ?? DEFAULTS.break_threshold_hours,
),
break_duration_short:
row.break_duration_short ?? DEFAULTS.break_duration_short,
break_duration_long:
row.break_duration_long ?? DEFAULTS.break_duration_long,
clock_rounding_minutes:
row.clock_rounding_minutes ?? DEFAULTS.clock_rounding_minutes,
invoice_alert_email: row.invoice_alert_email || "",
leave_notify_email: row.leave_notify_email || "",
max_login_attempts: row.max_login_attempts ?? DEFAULTS.max_login_attempts,
lockout_minutes: row.lockout_minutes ?? DEFAULTS.lockout_minutes,
max_requests_per_minute:
row.max_requests_per_minute ?? DEFAULTS.max_requests_per_minute,
default_currency: row.default_currency || DEFAULTS.default_currency,
default_vat_rate: Number(row.default_vat_rate ?? DEFAULTS.default_vat_rate),
available_vat_rates: vatRates,
available_currencies: currencies,
smtp_from: row.smtp_from || "",
smtp_from_name: row.smtp_from_name || DEFAULTS.smtp_from_name,
};
cacheTime = Date.now();
return cache;
}
export function invalidateSettingsCache(): void {
cache = null;
cacheTime = 0;
}