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:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
97
src/services/system-settings.ts
Normal file
97
src/services/system-settings.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user