fix: 2026-06-09 full-codebase audit hardening

Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -348,5 +348,13 @@ export async function extractInvoice(
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return JSON.parse(text) as ExtractedInvoice;
// The json_schema output format makes a valid-JSON reply the norm, but a
// truncated/non-JSON reply must not surface as a raw SyntaxError — throw a
// clear, typed error the route's catch can turn into a friendly message.
try {
return JSON.parse(text) as ExtractedInvoice;
} catch (e) {
console.error("[ai.service] extractInvoice: model reply was not JSON", e);
throw new Error("Model nevrátil platná data faktury (neplatný JSON)");
}
}

View File

@@ -29,6 +29,9 @@ type AttendanceWithRelations = Prisma.attendanceGetPayload<{
const VALID_LEAVE_TYPES = ["work", "vacation", "sick", "unpaid"] as const;
/** Default annual vacation allowance (hours) when a user has no leave_balances row. */
const DEFAULT_VACATION_TOTAL = 160;
const MONTH_NAMES = [
"Leden",
"Únor",
@@ -82,7 +85,10 @@ async function lockUserRow(
tx: Prisma.TransactionClient,
userId: number,
): Promise<void> {
await tx.$executeRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
// Must be $queryRaw (not $executeRaw): $executeRaw is for write statements
// and may not reliably acquire/hold the row lock for a bare SELECT ... FOR
// UPDATE. $queryRaw runs it as a query so the lock is actually taken.
await tx.$queryRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
}
// ── Interfaces ───────────────────────────────────────────────────────
@@ -184,49 +190,58 @@ export async function getStatus(userId: number) {
const todayStart = new Date(y, m, d, 0, 0, 0);
const todayEnd = new Date(y, m, d, 23, 59, 59);
// 1) Ongoing shift (no departure)
const ongoingShift = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
// Monthly fund range (used by query #4)
const monthStart = new Date(y, m, 1);
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
// 2) Today's completed shifts
const todayShiftsRaw = await prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lte: todayEnd },
departure_time: { not: null },
},
include: {
attendance_project_logs: { orderBy: { started_at: "asc" } },
},
orderBy: { arrival_time: "asc" },
});
// Queries 1-4 are independent of one another → run them in parallel.
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
await Promise.all([
// 1) Ongoing shift (no departure)
prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
}),
// 2) Today's completed shifts
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lte: todayEnd },
departure_time: { not: null },
},
include: {
attendance_project_logs: { orderBy: { started_at: "asc" } },
},
orderBy: { arrival_time: "asc" },
}),
// 3) Leave balance
prisma.leave_balances.findFirst({
where: { user_id: userId, year: y },
}),
// 4) Monthly fund records
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: monthStart, lte: monthEnd },
},
}),
]);
// 3) Leave balance
const balance = await prisma.leave_balances.findFirst({
where: { user_id: userId, year: y },
});
const leaveBalance = {
vacation_total: balance ? Number(balance.vacation_total) : 160,
vacation_total: balance
? Number(balance.vacation_total)
: DEFAULT_VACATION_TOTAL,
vacation_used: balance ? Number(balance.vacation_used) : 0,
vacation_remaining: balance
? Number(balance.vacation_total) - Number(balance.vacation_used)
: 160,
: DEFAULT_VACATION_TOTAL,
sick_used: balance ? Number(balance.sick_used) : 0,
};
// 4) Monthly fund
const monthStart = new Date(y, m, 1);
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
const monthRecords = await prisma.attendance.findMany({
where: { user_id: userId, shift_date: { gte: monthStart, lte: monthEnd } },
});
const workingDays = getBusinessDaysInMonth(y, m);
const fund = workingDays * 8;
@@ -487,11 +502,19 @@ export async function getBalances(year: number) {
sick_used: number;
}
> = {};
// Single query for every user's balance for the year (was an N+1 loop).
const balanceRows = await prisma.leave_balances.findMany({
where: { year, user_id: { in: users.map((u) => u.id) } },
});
// Keep the first row seen per user (matches the old per-user findFirst).
const balanceByUser = new Map<number, (typeof balanceRows)[number]>();
for (const row of balanceRows) {
if (!balanceByUser.has(row.user_id)) balanceByUser.set(row.user_id, row);
}
for (const u of users) {
const lb = await prisma.leave_balances.findFirst({
where: { user_id: u.id, year },
});
const vTotal = lb ? Number(lb.vacation_total) : 160;
const lb = balanceByUser.get(u.id);
const vTotal = lb ? Number(lb.vacation_total) : DEFAULT_VACATION_TOTAL;
const vUsed = lb ? Number(lb.vacation_used) : 0;
const sUsed = lb ? Number(lb.sick_used) : 0;
balances[String(u.id)] = {
@@ -947,9 +970,10 @@ export async function getPrintData(
for (const bal of balanceRecords) {
const uid = String(bal.user_id);
leaveBalances[uid] = {
vacation_total: Number(bal.vacation_total) || 160,
vacation_total: Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL,
vacation_remaining:
(Number(bal.vacation_total) || 160) - (Number(bal.vacation_used) || 0),
(Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL) -
(Number(bal.vacation_used) || 0),
};
}
@@ -1102,7 +1126,7 @@ export async function handleBalances(data: BalancesData) {
create: {
user_id: data.user_id,
year: yr,
vacation_total: Number(data.vacation_total) || 160,
vacation_total: Number(data.vacation_total) || DEFAULT_VACATION_TOTAL,
vacation_used: Number(data.vacation_used) || 0,
sick_used: Number(data.sick_used) || 0,
},
@@ -1117,7 +1141,7 @@ export async function handleBalances(data: BalancesData) {
create: {
user_id: data.user_id,
year: yr,
vacation_total: 160,
vacation_total: DEFAULT_VACATION_TOTAL,
vacation_used: 0,
sick_used: 0,
},
@@ -1215,15 +1239,20 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const start = new Date(dateFrom);
const end = new Date(dateTo);
const perDayHours = data.leave_hours ? Number(data.leave_hours) : 8;
let created = 0;
try {
await prisma.$transaction(async (tx) => {
// A leave range can span a year boundary (e.g. Dec → Jan). Each day's
// hours must be booked against ITS OWN year's balance, so accumulate
// per calendar year rather than attributing the whole range to the
// start year.
const hoursByYear = new Map<number, number>();
const current = new Date(start);
while (current <= end) {
const dow = current.getDay();
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
current.getFullYear(),
current.getMonth(),
@@ -1243,46 +1272,47 @@ export async function createLeave(data: LeaveData, authUserId: number) {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
leave_hours: perDayHours,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
const dayYear = current.getFullYear();
hoursByYear.set(
dayYear,
(hoursByYear.get(dayYear) ?? 0) + perDayHours,
);
}
current.setDate(current.getDate() + 1);
}
const totalLeaveHours =
created * (data.leave_hours ? Number(data.leave_hours) : 8);
if (
(leaveType === "vacation" || leaveType === "sick") &&
totalLeaveHours > 0
) {
const year = new Date(dateFrom).getFullYear();
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year },
});
if (existingBalance) {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]:
Number(existingBalance[updateField]) + totalLeaveHours,
updated_at: new Date(),
},
});
} else {
await tx.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: 160,
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
},
if (leaveType === "vacation" || leaveType === "sick") {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
for (const [year, yearHours] of hoursByYear) {
if (yearHours <= 0) continue;
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year },
});
if (existingBalance) {
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]: Number(existingBalance[updateField]) + yearHours,
updated_at: new Date(),
},
});
} else {
await tx.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: DEFAULT_VACATION_TOTAL,
vacation_used: leaveType === "vacation" ? yearHours : 0,
sick_used: leaveType === "sick" ? yearHours : 0,
},
});
}
}
}
});
@@ -1691,10 +1721,25 @@ export async function updateAttendance(
return { id };
}
export async function deleteAttendance(id: number) {
export async function deleteAttendance(
id: number,
requesterId?: number,
isAdmin?: boolean,
) {
const existing = await prisma.attendance.findUnique({ where: { id } });
if (!existing) return { error: "Záznam nenalezen" };
// Ownership is enforced ONLY when a requester is supplied, keeping the
// signature backward compatible (existing callers pass nothing and skip
// the check, behaving exactly as before).
if (
requesterId !== undefined &&
!isAdmin &&
existing.user_id !== requesterId
) {
return { error: "Nemáte oprávnění smazat tento záznam", status: 403 };
}
await prisma.attendance.delete({ where: { id } });
return { success: true };
}

View File

@@ -1,4 +1,5 @@
import { FastifyRequest } from "fastify";
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import { AuditAction, EntityType, AuthData } from "../types";
@@ -9,16 +10,19 @@ import { AuditAction, EntityType, AuthData } from "../types";
*/
function safeJsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "bigint") return String(value);
// Precise Decimal detection via instanceof (Prisma.Decimal is decimal.js),
// with a structural fallback for Decimal instances that crossed a module
// boundary and fail the instanceof check.
if (value instanceof Prisma.Decimal) return value.toString();
if (
value !== null &&
typeof value === "object" &&
"toString" in value &&
typeof (value as any).toString === "function" &&
"toNumber" in value &&
typeof (value as any).toNumber === "function"
typeof (value as { toNumber?: unknown }).toNumber === "function" &&
"toString" in value &&
typeof (value as { toString?: unknown }).toString === "function"
) {
// Prisma Decimal
return (value as any).toString();
return (value as { toString(): string }).toString();
}
return value;
}

View File

@@ -270,12 +270,26 @@ export async function refreshAccessToken(
`;
const storedToken = tokens[0] ?? null;
// Detected reuse: a token that exists but has ALREADY been rotated
// (replaced_at / replaced_by_hash set) is being presented again. This is
// the classic refresh-token-theft signature — either the legitimate
// client or an attacker is replaying a consumed token. Breach
// containment: revoke the WHOLE token family for that user so both the
// stolen and the still-valid descendant tokens are invalidated, forcing
// a fresh login.
if (
!storedToken ||
storedToken.replaced_at ||
storedToken.replaced_by_hash ||
new Date(storedToken.expires_at) < new Date()
storedToken &&
(storedToken.replaced_at || storedToken.replaced_by_hash)
) {
const reuseUserId = Number(storedToken.user_id);
await tx.refresh_tokens.deleteMany({ where: { user_id: reuseUserId } });
request.log.warn(
`Refresh-token reuse detected for user ${reuseUserId}; revoked all sessions`,
);
return { type: "error", message: "Neplatný refresh token", status: 401 };
}
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
return { type: "error", message: "Neplatný refresh token", status: 401 };
}

View File

@@ -10,19 +10,23 @@ interface CnbRate {
amount: number;
}
const rateCache = new Map<string, Record<string, number>>();
let rateCacheTime = 0;
// Per-key cache: each key carries its OWN fetch timestamp so a hit on one date
// never refreshes the TTL of another. A stale entry is only re-fetched (and
// only that key is checked for the stale fallback).
const rateCache = new Map<
string,
{ rates: Record<string, number>; time: number }
>();
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const FETCH_TIMEOUT_MS = 8000; // abort a hung ČNB endpoint
const inflight = new Map<string, Promise<Record<string, number>>>();
async function fetchRatesForDate(
date?: string,
): Promise<Record<string, number>> {
const key = date || "today";
if (Date.now() - rateCacheTime > CACHE_TTL_MS) {
rateCache.clear();
}
if (rateCache.has(key)) return rateCache.get(key)!;
const cached = rateCache.get(key);
if (cached && Date.now() - cached.time <= CACHE_TTL_MS) return cached.rates;
if (inflight.has(key)) return inflight.get(key)!;
const promise = (async () => {
@@ -30,22 +34,29 @@ async function fetchRatesForDate(
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
if (date) url += `&date=${date}`;
const response = await fetch(url);
const response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`CNB API: ${response.status}`);
const data = (await response.json()) as { rates: CnbRate[] };
const data = (await response.json()) as { rates?: CnbRate[] };
if (!Array.isArray(data.rates)) {
throw new Error("CNB API: neočekávaný formát odpovědi (rates)");
}
const rates: Record<string, number> = { CZK: 1 };
for (const r of data.rates) {
rates[r.currencyCode] = r.rate / r.amount;
}
rateCache.set(key, rates);
rateCacheTime = Date.now();
rateCache.set(key, { rates, time: Date.now() });
return rates;
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache.has("today")) return rateCache.get("today")!;
// Stale-cache fallback for the SAME key (e.g. a hung/aborted refresh of
// an entry that is past TTL — better to serve yesterday's rate than fail).
const stale = rateCache.get(key);
if (stale) return stale.rates;
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
} finally {
inflight.delete(key);
@@ -56,6 +67,18 @@ async function fetchRatesForDate(
return promise;
}
/**
* Test-only: clear the module-level rate cache (and any in-flight fetches).
* The cache persists for the process lifetime, so tests that assert on
* specific fetched rates must reset it between runs to avoid order-dependent
* results (a populated "today" entry would otherwise short-circuit the mock).
* Not used by runtime code.
*/
export function __resetRateCacheForTest(): void {
rateCache.clear();
inflight.clear();
}
/** Convert an amount from a given currency to CZK using CNB rates */
export async function toCzk(
amount: number,

View File

@@ -7,6 +7,7 @@ import { getSystemSettings } from "./system-settings";
interface AlertInvoice {
id: number;
type: "created" | "received";
alert_type: "due" | "3days";
number: string;
counterparty: string;
amount: string;
@@ -20,7 +21,8 @@ function escapeHtml(str: string): string {
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatAmount(n: number | { toNumber?: () => number }): string {
@@ -43,125 +45,134 @@ export async function checkInvoiceAlerts(): Promise<void> {
in3days.setDate(in3days.getDate() + 3);
const in3daysStr = localDateStr(in3days);
const alerts: AlertInvoice[] = [];
// Classify a due date into an alert type/label, or null if it doesn't match.
const classify = (
dueDate: Date,
): { alertType: "due" | "3days"; daysLabel: string } | null => {
const dueDateStr = localDateStr(dueDate);
if (dueDateStr === todayStr)
return { alertType: "due", daysLabel: "splatnost dnes" };
if (dueDateStr === in3daysStr)
return { alertType: "3days", daysLabel: "splatnost za 3 dny" };
return null;
};
// --- Created invoices (customer owes us) ---
const createdInvoices = await prisma.invoices.findMany({
where: {
status: { in: ["issued", "overdue"] },
due_date: { gte: today, lte: in3days },
},
select: {
id: true,
invoice_number: true,
due_date: true,
currency: true,
customers: { select: { name: true } },
invoice_items: { select: { quantity: true, unit_price: true } },
},
});
for (const inv of createdInvoices) {
if (!inv.due_date) continue;
const dueDateStr = localDateStr(new Date(inv.due_date));
let alertType: string | null = null;
let daysLabel = "";
if (dueDateStr === todayStr) {
alertType = "due";
daysLabel = "splatnost dnes";
} else if (dueDateStr === in3daysStr) {
alertType = "3days";
daysLabel = "splatnost za 3 dny";
}
if (!alertType) continue;
const alreadySent = await prisma.invoice_alert_log.findUnique({
// --- Gather candidates (created + received) before touching the alert log ---
const [createdInvoices, receivedInvoices] = await Promise.all([
prisma.invoices.findMany({
where: {
invoice_type_invoice_id_alert_type: {
invoice_type: "created",
invoice_id: inv.id,
alert_type: alertType,
status: { in: ["issued", "overdue"] },
due_date: { gte: today, lte: in3days },
},
select: {
id: true,
invoice_number: true,
due_date: true,
currency: true,
apply_vat: true,
vat_rate: true,
customers: { select: { name: true } },
invoice_items: {
select: { quantity: true, unit_price: true, vat_rate: true },
},
},
});
if (alreadySent) continue;
}),
prisma.received_invoices.findMany({
where: {
status: "unpaid",
due_date: { gte: today, lte: in3days },
},
select: {
id: true,
invoice_number: true,
supplier_name: true,
amount: true,
currency: true,
due_date: true,
},
}),
]);
// Build the candidate set (matched-on-date, not yet de-duped against the log)
// so we can batch-load the alert log in a single query.
type Candidate = AlertInvoice;
const createdCandidates: Candidate[] = [];
for (const inv of createdInvoices) {
if (!inv.due_date) continue;
const c = classify(new Date(inv.due_date));
if (!c) continue;
// Gross total (subtotal + VAT) — consistent with the gross amounts shown
// for received invoices. VAT is computed per line, rounded to 2 decimals.
const subtotal = inv.invoice_items.reduce(
(sum, item) => sum + Number(item.quantity) * Number(item.unit_price),
0,
);
const vatTotal = inv.apply_vat
? inv.invoice_items.reduce((sum, item) => {
const base = Number(item.quantity) * Number(item.unit_price);
const rate = Number(item.vat_rate) || Number(inv.vat_rate) || 21;
return sum + Math.round(base * (rate / 100) * 100) / 100;
}, 0)
: 0;
const gross = Math.round((subtotal + vatTotal) * 100) / 100;
alerts.push({
createdCandidates.push({
id: inv.id,
type: "created",
alert_type: c.alertType,
number: inv.invoice_number || `#${inv.id}`,
counterparty: inv.customers?.name || "—",
amount: formatAmount(subtotal),
amount: formatAmount(gross),
currency: inv.currency || "CZK",
due_date: localDateCzStr(new Date(inv.due_date)),
days_label: daysLabel,
days_label: c.daysLabel,
});
}
// --- Received invoices (we owe supplier) ---
const receivedInvoices = await prisma.received_invoices.findMany({
where: {
status: "unpaid",
due_date: { gte: today, lte: in3days },
},
select: {
id: true,
invoice_number: true,
supplier_name: true,
amount: true,
currency: true,
due_date: true,
},
});
const receivedCandidates: Candidate[] = [];
for (const inv of receivedInvoices) {
if (!inv.due_date) continue;
const dueDateStr = localDateStr(new Date(inv.due_date));
const c = classify(new Date(inv.due_date));
if (!c) continue;
let alertType: string | null = null;
let daysLabel = "";
if (dueDateStr === todayStr) {
alertType = "due";
daysLabel = "splatnost dnes";
} else if (dueDateStr === in3daysStr) {
alertType = "3days";
daysLabel = "splatnost za 3 dny";
}
if (!alertType) continue;
const alreadySent = await prisma.invoice_alert_log.findUnique({
where: {
invoice_type_invoice_id_alert_type: {
invoice_type: "received",
invoice_id: inv.id,
alert_type: alertType,
},
},
});
if (alreadySent) continue;
alerts.push({
receivedCandidates.push({
id: inv.id,
type: "received",
alert_type: c.alertType,
number: inv.invoice_number || inv.supplier_name,
counterparty: inv.supplier_name,
// received_invoices.amount is GROSS (VAT-inclusive) by convention.
amount: formatAmount(inv.amount),
currency: inv.currency,
due_date: localDateCzStr(new Date(inv.due_date)),
days_label: daysLabel,
days_label: c.daysLabel,
});
}
const candidates = [...createdCandidates, ...receivedCandidates];
if (candidates.length === 0) return;
// Batch-load every already-sent log entry for the candidate set in ONE query
// (instead of a findUnique per invoice).
const sentLogs = await prisma.invoice_alert_log.findMany({
where: {
OR: candidates.map((c) => ({
invoice_type: c.type,
invoice_id: c.id,
alert_type: c.alert_type,
})),
},
select: { invoice_type: true, invoice_id: true, alert_type: true },
});
const sentKeys = new Set(
sentLogs.map((l) => `${l.invoice_type}:${l.invoice_id}:${l.alert_type}`),
);
const alerts: AlertInvoice[] = candidates.filter(
(c) => !sentKeys.has(`${c.type}:${c.id}:${c.alert_type}`),
);
if (alerts.length === 0) return;
// --- Build summary email ---
@@ -223,21 +234,21 @@ export async function checkInvoiceAlerts(): Promise<void> {
console.log(`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`);
// Mark alerts as sent only after successful delivery
for (const a of alerts) {
await prisma.invoice_alert_log.create({
data: {
// Mark alerts as sent only after successful delivery. One INSERT with
// skipDuplicates so a unique-constraint hit (concurrent run) on one row
// doesn't abort logging the rest.
try {
await prisma.invoice_alert_log.createMany({
data: alerts.map((a) => ({
invoice_type: a.type,
invoice_id: a.id,
alert_type:
a.type === "created"
? a.days_label.includes("dnes")
? "due"
: "3days"
: a.days_label.includes("dnes")
? "due"
: "3days",
},
alert_type: a.alert_type,
})),
skipDuplicates: true,
});
} catch (err) {
// Non-fatal: the email already went out. Log so a failed bookkeeping
// write is visible (and may cause a duplicate alert on the next run).
console.error("InvoiceAlerts: failed to record alert log:", err);
}
}

View File

@@ -22,15 +22,85 @@ const ALLOWED_SORT_FIELDS = [
];
interface InvoiceItemInput {
description?: string;
item_description?: string;
description?: string | null;
item_description?: string | null;
quantity?: number;
unit?: string;
unit?: string | null;
unit_price?: number;
vat_rate?: number;
position?: number;
}
/**
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
* loosely typed because the payload arrives validated-but-coerced from the Zod
* route layer (numbers may still be string-from-form). Kept permissive so the
* existing String()/Number() coercions below stay correct; replaces the old
* `Record<string, any>`.
*/
export interface InvoiceInput {
invoice_number?: string | number | null;
order_id?: number | string | null;
customer_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
apply_vat?: boolean | number | string;
payment_method?: string | null;
constant_symbol?: string | null;
bank_name?: string | null;
bank_swift?: string | null;
bank_iban?: string | null;
bank_account?: string | null;
issue_date?: string | null;
due_date?: string | null;
tax_date?: string | null;
issued_by?: string | null;
billing_text?: string | null;
language?: string;
notes?: string | null;
internal_notes?: string | null;
paid_date?: string | null;
items?: InvoiceItemInput[];
[key: string]: unknown;
}
/**
* Single rounding-correct money/VAT core. All three former implementations
* (computeInvoiceTotals, invoiceTotalWithVat, the stats VAT loop) funnel through
* this so the subtotal/accumulation/rounding logic cannot diverge again.
*
* Each caller resolves its own per-line VAT *rate* before calling (the three
* paths historically differ in how a line rate of 0 is treated — that nuance
* is preserved by keeping rate resolution caller-side via `resolveRate`).
*
* - `subtotal` = Σ(qty × unit_price) over lines.
* - `vat` = Σ of per-line VAT. When `roundPerLine` is true each line's VAT is
* rounded to 2 decimals BEFORE accumulation (invoice totals); when false the
* raw per-line VAT is accumulated and the caller rounds the sum (stats).
*
* Numeric outputs are byte-for-byte identical to the previous three code paths.
*/
function computeMoney<T>(
lines: T[],
applyVat: boolean | null,
getBase: (line: T) => number,
resolveRate: (line: T) => number,
roundPerLine: boolean,
): { subtotal: number; vat: number } {
let subtotal = 0;
let vat = 0;
for (const line of lines) {
const base = getBase(line);
subtotal += base;
if (applyVat) {
const lineVat = base * (resolveRate(line) / 100);
vat += roundPerLine ? Math.round(lineVat * 100) / 100 : lineVat;
}
}
return { subtotal, vat };
}
interface ListInvoicesParams {
page: number;
limit: number;
@@ -49,23 +119,19 @@ function computeInvoiceTotals(
applyVat: boolean | null,
defaultVatRate: unknown,
) {
const subtotal = items.reduce(
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
const { subtotal, vat: vatAmount } = computeMoney(
items,
applyVat,
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
(i) =>
i.vat_rate != null && i.vat_rate !== ""
? Number(i.vat_rate)
: defaultVatRate != null
? Number(defaultVatRate)
: 21,
true, // round each line's VAT before accumulation
);
const vatAmount = applyVat
? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
const vatRate =
i.vat_rate != null && i.vat_rate !== ""
? Number(i.vat_rate)
: defaultVatRate != null
? Number(defaultVatRate)
: 21;
const vat = base * (vatRate / 100);
return s + Math.round(vat * 100) / 100;
}, 0)
: 0;
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
@@ -171,28 +237,19 @@ export function invoiceTotalWithVat(inv: {
vat_rate: { toNumber(): number } | null;
}>;
}) {
const sub = inv.invoice_items.reduce(
(s, i) =>
s +
const { subtotal, vat } = computeMoney(
inv.invoice_items,
inv.apply_vat,
(i) =>
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0),
0,
(Number(i.unit_price?.toNumber()) || 0),
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
// the invoice default, then 21.
(i) =>
Number(i.vat_rate?.toNumber()) || Number(inv.vat_rate?.toNumber()) || 21,
true, // round each line's VAT before accumulation
);
const vat = inv.apply_vat
? inv.invoice_items.reduce((s, i) => {
const base =
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0);
const lineVat =
base *
((Number(i.vat_rate?.toNumber()) ||
Number(inv.vat_rate?.toNumber()) ||
21) /
100);
return s + Math.round(lineVat * 100) / 100;
}, 0)
: 0;
return sub + vat;
return subtotal + vat;
}
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
@@ -257,17 +314,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
// VAT for the month, per currency. Uses the shared money core with
// roundPerLine=false (the stats path historically accumulates RAW per-line
// VAT and rounds only the final sum — preserved byte-for-byte).
const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
if (!inv.apply_vat) continue;
const cur = inv.currency || "CZK";
for (const item of inv.invoice_items) {
const base =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const vat =
base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
vatMap[cur] = (vatMap[cur] || 0) + vat;
}
const { vat } = computeMoney(
inv.invoice_items,
inv.apply_vat,
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
false, // accumulate raw per-line VAT; round only the per-currency sum
);
vatMap[cur] = (vatMap[cur] || 0) + vat;
}
const vatAmounts = Object.entries(vatMap)
.filter(([, v]) => v > 0)
@@ -275,21 +336,31 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
amount: Math.round(amount * 100) / 100,
currency,
}));
// VAT also needs conversion
let vatCzkConverted = 0;
for (const [cur, amount] of Object.entries(vatMap)) {
vatCzkConverted += await toCzk(amount, cur);
}
// VAT also needs CZK conversion — run each currency's toCzk concurrently.
const vatCzkConverted = (
await Promise.all(
Object.entries(vatMap).map(([cur, amount]) => toCzk(amount, cur)),
)
).reduce((s, v) => s + v, 0);
// These aggregates/conversions are independent — run them concurrently
// instead of serially awaiting each in the return literal.
const [paidMonthCzk, awaitingCzk, overdueCzk] = await Promise.all([
sumCzk(paidInvoices),
sumCzk(awaitingInvoices),
sumCzk(overdueInvoices),
]);
return {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: await sumCzk(paidInvoices),
paid_month_czk: paidMonthCzk,
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: await sumCzk(awaitingInvoices),
awaiting_czk: awaitingCzk,
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: await sumCzk(overdueInvoices),
overdue_czk: overdueCzk,
overdue_count: overdueInvoices.length,
vat_month: vatAmounts,
vat_month_czk: Math.round(vatCzkConverted * 100) / 100,
@@ -315,6 +386,9 @@ export async function getOrderDataForInvoice(orderId: number) {
};
}
// NOTE: returns `null` (not `{ error, status }`) on not-found — intentional and
// load-bearing: routes/admin/invoices.ts checks `if (!invoice) 404`. Returning a
// truthy `{ error }` object here would bypass that 404 guard, so this stays null.
export async function getInvoice(id: number) {
const invoice = await prisma.invoices.findUnique({
where: { id },
@@ -336,7 +410,7 @@ export async function getInvoice(id: number) {
};
}
export async function createInvoice(body: Record<string, any>) {
export async function createInvoice(body: InvoiceInput) {
const invoiceNumber =
body.invoice_number !== undefined && body.invoice_number !== null
? String(body.invoice_number)
@@ -372,7 +446,7 @@ export async function createInvoice(body: Record<string, any>) {
if (Array.isArray(body.items)) {
await prisma.invoice_items.createMany({
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
data: body.items.map((item, i) => ({
invoice_id: invoice.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
@@ -388,7 +462,7 @@ export async function createInvoice(body: Record<string, any>) {
return invoice;
}
export async function updateInvoice(id: number, body: Record<string, any>) {
export async function updateInvoice(id: number, body: InvoiceInput) {
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
@@ -470,7 +544,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
await prisma.$transaction(async (tx) => {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
data: (body.items as InvoiceItemInput[]).map((item, i) => ({
data: body.items!.map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
@@ -493,9 +567,13 @@ export async function deleteInvoice(id: number) {
await prisma.invoices.delete({ where: { id } });
const year = existing.invoice_number
? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
: new Date().getFullYear();
// Derive the sequence year from the invoice number ("<seq>/<YYYY>"). Guard the
// split: a non-standard number (no "/" or a non-4-digit / non-numeric part)
// falls back to the current year rather than releasing a bogus sequence.
const yearPart = existing.invoice_number?.split("/")[1];
const parsedYear =
yearPart && /^\d{4}$/.test(yearPart) ? Number(yearPart) : NaN;
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear;
await releaseInvoiceNumber(year, existing.invoice_number ?? undefined);
return existing;

View File

@@ -10,12 +10,12 @@ const LEAVE_TYPE_LABELS: Record<string, string> = {
};
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return localDateCzStr(d);
} catch {
return dateStr;
}
// `new Date(invalid)` returns an Invalid Date object (it never throws), so a
// try/catch can't detect a bad input — check the timestamp explicitly and
// fall back to the raw string when unparseable.
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return dateStr;
return localDateCzStr(d);
}
function escapeHtml(str: string): string {
@@ -31,7 +31,7 @@ function escapeHtml(str: string): string {
// can set their first_name to "attacker\nBcc: victim@x.com" via the
// profile editor and pivot into sending mail as noreply@boha.cz.
function sanitizeHeaderValue(str: string): string {
// eslint-disable-next-line no-control-regex
return str
.replace(/[\r\n\t\0]/g, " ")
.replace(/\s+/g, " ")

View File

@@ -2,17 +2,49 @@ import nodemailer from "nodemailer";
import { config } from "../config/env";
import { getSystemSettings } from "./system-settings";
const transporter = nodemailer.createTransport({
sendmail: true,
newline: "unix",
path: "/usr/sbin/sendmail",
});
// Default transport: local sendmail (unchanged prod behaviour). When
// SMTP_HOST is set in the environment, switch to an SMTP transport instead —
// this is purely opt-in: with the var absent, behaviour is identical to before.
// (We read process.env directly here so no .env edit is required to enable it.)
function buildTransporter() {
const host = process.env.SMTP_HOST;
if (host) {
const port = Number(process.env.SMTP_PORT) || 587;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
// secure=true for implicit TLS (465), STARTTLS otherwise.
const secure =
process.env.SMTP_SECURE === "true" ||
process.env.SMTP_SECURE === "1" ||
port === 465;
return nodemailer.createTransport({
host,
port,
secure,
auth: user && pass ? { user, pass } : undefined,
});
}
return nodemailer.createTransport({
sendmail: true,
newline: "unix",
path: "/usr/sbin/sendmail",
});
}
const transporter = buildTransporter();
export async function sendMail(
to: string,
subject: string,
html: string,
): Promise<boolean> {
// Guard against an empty/blank recipient — nodemailer would either throw or
// silently produce an envelope with no recipient.
if (typeof to !== "string" || to.trim() === "") {
console.error("Mailer error: missing or empty recipient");
return false;
}
const settings = await getSystemSettings();
const from =
settings.smtp_from ||

View File

@@ -675,7 +675,17 @@ export class NasFileManager {
return folderPath;
}
// Basic path traversal protection
// Basic path traversal protection.
// NOTE (audit finding, accepted): ".." is matched as a LITERAL substring,
// which would also reject a legitimate filename containing ".." — that's an
// acceptable conservative trade-off and the real traversal is contained by
// the `candidate.startsWith(normalFolder + "/")` prefix check below plus the
// symlink walk. There is also an inherent TOCTOU window between this
// validation and the later fs op on the returned path: a symlink swapped
// into a path component AFTER walkAndRejectSymlinks runs could in theory
// bypass the guard. Risk is low on a trusted single-tenant NAS share;
// closing it fully would require opening with O_NOFOLLOW per component
// (not portable on the SMB mount) — deliberately NOT redesigned here.
if (subPath.includes("\0") || subPath.includes("..")) {
return null;
}

View File

@@ -5,6 +5,18 @@ import { config } from "../config/env";
/**
* NAS storage for financial documents.
* Structure: {basePath}/Vydané/YYYY/MM/ and {basePath}/Přijaté/YYYY/MM/
*
* PERFORMANCE NOTE (audit finding — DEFERRED, intentional):
* Every method here uses SYNCHRONOUS fs calls (mkdirSync/writeFileSync/
* readFileSync/readdirSync/statSync). When basePath is a network (SMB) share, a
* slow or hung NAS will BLOCK the Node event loop for the duration of the call,
* stalling all other requests in the same process. This is a known trade-off:
* the file sizes are small (invoice PDFs), the call sites are low-frequency
* admin operations, and the synchronous API lets callers treat a returned path
* as a verified, durable write (see migrate-received-invoices-to-nas.ts, which
* relies on this to safely null out the DB BLOB). Converting to the async
* fs.promises API is a large, cross-cutting change touching every caller and
* their error handling — deliberately NOT done here to keep this fix scoped.
*/
const DIR_ISSUED = "Vydané";
@@ -21,6 +33,9 @@ class NasFinancialsManager {
isConfigured(): boolean {
if (!this.basePath) return false;
try {
// Intentional side effect: ensure the base directory exists as part of the
// configured-check so the first save doesn't fail on a missing root. This
// mirrors nas-offers-manager.isConfigured() (established convention).
fs.mkdirSync(this.basePath, { recursive: true });
return fs.statSync(this.basePath).isDirectory();
} catch (err) {
@@ -78,6 +93,11 @@ class NasFinancialsManager {
month: number,
pdfBuffer: Buffer,
): string | null {
// Guard parity with saveReceivedInvoice: without this, an unconfigured
// (empty) basePath would let ensureDir build a RELATIVE path and write the
// PDF into the process CWD instead of the NAS. Bail out instead.
if (!this.isConfigured()) return null;
const dir = this.ensureDir(DIR_ISSUED, year, month);
if (!dir) return null;

View File

@@ -21,7 +21,12 @@ class NasOffersManager {
try {
fs.mkdirSync(this.basePath, { recursive: true });
return fs.statSync(this.basePath).isDirectory();
} catch {
} catch (err) {
console.error(
"[nas-offers-manager] mkdir/stat failed for basePath:",
this.basePath,
err,
);
return false;
}
}
@@ -43,7 +48,12 @@ class NasOffersManager {
try {
fs.writeFileSync(fullPath, pdfBuffer);
return `${year}/${folderName}/${fileName}`;
} catch {
} catch (err) {
console.error(
"[nas-offers-manager] write failed for offer PDF:",
fullPath,
err,
);
return null;
}
}
@@ -56,7 +66,12 @@ class NasOffersManager {
try {
const data = fs.readFileSync(fullPath);
return { data, fileName: path.basename(fullPath) };
} catch {
} catch (err) {
console.error(
"[nas-offers-manager] read failed for offer PDF:",
fullPath,
err,
);
return null;
}
}
@@ -73,7 +88,12 @@ class NasOffersManager {
fs.rmdirSync(dir);
}
return true;
} catch {
} catch (err) {
console.error(
"[nas-offers-manager] delete failed for offer PDF:",
fullPath,
err,
);
return false;
}
}
@@ -108,7 +128,12 @@ class NasOffersManager {
try {
fs.mkdirSync(dirPath, { recursive: true });
return dirPath;
} catch {
} catch (err) {
console.error(
"[nas-offers-manager] mkdir failed for path:",
dirPath,
err,
);
return null;
}
}

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
// Prisma transaction client (omit methods not available inside $transaction)
@@ -57,7 +58,16 @@ export async function getNextSequence(
year: number,
tx?: TxClient,
): Promise<number> {
const exec = async (client: TxClient) => {
// Read-modify-write under a row lock. The very first allocation of a
// (type, year) pair has no row to lock yet, so two concurrent callers can
// both see zero rows and both attempt the INSERT — the @@unique([type,year])
// constraint then raises P2002 on the loser. `allowInsertRetry` lets us catch
// that one case and fall back to the locked read-then-update path, so the
// first-of-year race returns the next sequence instead of a 500.
const exec = async (
client: TxClient,
allowInsertRetry: boolean,
): Promise<number> => {
const existing = await client.$queryRaw<
Array<{ id: number; last_number: number }>
>`
@@ -67,11 +77,24 @@ export async function getNextSequence(
`;
if (existing.length === 0) {
await client.$executeRaw`
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
VALUES (${type}, ${year}, 1)
`;
return 1;
try {
await client.$executeRaw`
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
VALUES (${type}, ${year}, 1)
`;
return 1;
} catch (err) {
// Concurrent first-of-year insert won the race. Retry the locked
// read-then-update exactly once; the row now exists so this resolves.
if (
allowInsertRetry &&
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return exec(client, false);
}
throw err;
}
}
const next = existing[0].last_number + 1;
@@ -84,9 +107,9 @@ export async function getNextSequence(
};
if (tx) {
return exec(tx);
return exec(tx, true);
}
return prisma.$transaction(exec);
return prisma.$transaction((client) => exec(client, true));
}
/**
@@ -120,12 +143,7 @@ async function releaseSequence(
) {
if (!deletedNumber) return;
const existing = await prisma.$queryRaw<Array<{ last_number: number }>>`
SELECT last_number FROM number_sequences
WHERE \`type\` = ${type} AND \`year\` = ${year}
`;
if (existing.length === 0) return;
// Settings are read-only here and don't need to be inside the lock window.
const settings = await getSettings();
const pattern =
type === "shared"
@@ -141,20 +159,33 @@ async function releaseSequence(
: "";
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
const highestNumber = applyPattern(pattern, {
year,
prefix,
code,
seq: existing[0].last_number,
});
if (deletedNumber === highestNumber && existing[0].last_number > 0) {
await prisma.$executeRaw`
UPDATE number_sequences
SET \`last_number\` = ${existing[0].last_number - 1}
// Lock the sequence row for the duration of the decrement so a concurrent
// getNextSequence (which also takes FOR UPDATE) can't read-modify-write the
// same row in between — that interleaving could otherwise lose a decrement or
// decrement an in-use number (gap/dup).
await prisma.$transaction(async (tx) => {
const existing = await tx.$queryRaw<Array<{ last_number: number }>>`
SELECT last_number FROM number_sequences
WHERE \`type\` = ${type} AND \`year\` = ${year}
FOR UPDATE
`;
}
if (existing.length === 0) return;
const highestNumber = applyPattern(pattern, {
year,
prefix,
code,
seq: existing[0].last_number,
});
if (deletedNumber === highestNumber && existing[0].last_number > 0) {
await tx.$executeRaw`
UPDATE number_sequences
SET \`last_number\` = ${existing[0].last_number - 1}
WHERE \`type\` = ${type} AND \`year\` = ${year}
`;
}
});
}
/** Verify a shared number is not already used by an order or project. */

View File

@@ -5,6 +5,7 @@ import {
releaseOfferNumber,
isOfferNumberTaken,
} from "./numbering.service";
import { nasOffersManager } from "./nas-offers-manager";
interface QuotationItemInput {
description?: string;
@@ -159,71 +160,90 @@ export async function getOffer(id: number) {
}
export async function createOffer(body: Record<string, any>) {
if (body.quotation_number !== undefined && body.quotation_number !== null) {
const taken = await isOfferNumberTaken(String(body.quotation_number));
if (taken) {
return { error: "Číslo nabídky je již použito", status: 400 } as const;
}
}
try {
return await prisma.$transaction(async (tx) => {
const quotationNumber =
body.quotation_number !== undefined && body.quotation_number !== null
? String(body.quotation_number)
: await generateOfferNumber(tx);
return prisma.$transaction(async (tx) => {
const quotationNumber =
body.quotation_number !== undefined && body.quotation_number !== null
? String(body.quotation_number)
: await generateOfferNumber(tx);
// Re-check uniqueness INSIDE the transaction to close the TOCTOU window
// between a pre-transaction check and the insert (mirrors createOrder).
// generateOfferNumber already verifies its own generated numbers, so this
// guards the caller-supplied path.
if (
body.quotation_number !== undefined &&
body.quotation_number !== null
) {
const taken = await isOfferNumberTaken(quotationNumber);
if (taken) {
throw Object.assign(new Error("Číslo nabídky je již použito"), {
status: 400,
});
}
}
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
created_at: body.created_at
? new Date(String(body.created_at))
: undefined,
valid_until: body.valid_until
? new Date(String(body.valid_until))
: null,
currency: body.currency ? String(body.currency) : "CZK",
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
status: body.status ? String(body.status) : "active",
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
? String(body.scope_description)
: null,
},
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
created_at: body.created_at
? new Date(String(body.created_at))
: undefined,
valid_until: body.valid_until
? new Date(String(body.valid_until))
: null,
currency: body.currency ? String(body.currency) : "CZK",
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
status: body.status ? String(body.status) : "active",
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
? String(body.scope_description)
: null,
},
});
if (Array.isArray(body.items)) {
await tx.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return quotation;
});
if (Array.isArray(body.items)) {
await tx.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
}
if (Array.isArray(body.sections)) {
await tx.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return quotation;
});
throw err;
}
}
export async function updateOffer(id: number, body: Record<string, any>) {
@@ -334,6 +354,25 @@ export async function deleteOffer(id: number) {
: new Date().getFullYear();
await releaseOfferNumber(year, existing.quotation_number ?? undefined);
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
// file is already gone, so a NAS outage / missing file never throws here.
if (existing.quotation_number && nasOffersManager.isConfigured()) {
try {
const relPath = nasOffersManager.buildRelativePath(
existing.quotation_number,
year,
);
nasOffersManager.deleteOfferPdf(relPath);
} catch (e) {
console.error(
"[offers.service] NAS offer PDF delete failed for",
existing.quotation_number,
e,
);
}
}
return existing;
}

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import {
generateSharedNumber,
previewSharedNumber,
@@ -43,6 +44,32 @@ const ORDER_ALLOWED_SORT_FIELDS = [
"created_at",
];
// Order status -> linked-project status (matching PHP).
const ORDER_TO_PROJECT_STATUS: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
/**
* Propagate an order status change onto its linked project(s). No-op when the
* new status has no project-status mapping. Accepts a Prisma client (the tx
* client inside a transaction, or the base client otherwise) so both update
* branches share one implementation.
*/
async function syncProjectStatus(
client: Prisma.TransactionClient,
orderId: number,
newStatus: string,
): Promise<void> {
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
if (!projectStatus) return;
await client.projects.updateMany({
where: { order_id: orderId },
data: { status: projectStatus },
});
}
function enrichOrder(o: any) {
const subtotal = o.order_items
.filter((i: any) => i.is_included_in_total !== false)
@@ -523,18 +550,7 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await tx.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
await syncProjectStatus(tx, id, String(body.status));
}
if (Array.isArray(body.items)) {
@@ -570,18 +586,7 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
// Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) {
const statusMap: Record<string, string> = {
v_realizaci: "aktivni",
dokoncena: "dokonceny",
stornovana: "zruseny",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
await syncProjectStatus(prisma, id, String(body.status));
}
}
@@ -601,69 +606,85 @@ export async function deleteOrder(id: number, deleteFiles = false) {
select: { id: true, created_at: true, project_number: true },
});
// Guard: projects may have non-cascaded warehouse refs (sklad_issues,
// sklad_reservations, attendance_project_logs all use project_id as a
// non-nullable FK with no onDelete). Surface as 409 (resource conflict)
// rather than letting Prisma throw P2003 -> 500.
//
// Only ACTIVE records count: a CANCELLED issue/restored batch or
// CANCELLED reservation is audit-trail-only (cancelIssue() has already
// restored the batch qty; cancelReservation() zeroes remaining_qty).
// Attendance project logs are time-records — they always block, since
// deleting the project would orphan the time record. The user must
// re-assign the attendance log to a different project first.
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
const [activeIssuesCount, activeReservationsCount, attendanceLogCount] =
await Promise.all([
prisma.sklad_issues.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
prisma.sklad_reservations.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
prisma.attendance_project_logs.count({
try {
await prisma.$transaction(async (tx) => {
// Guard: projects may have non-cascaded warehouse refs (sklad_issues,
// sklad_reservations, attendance_project_logs all use project_id as a
// non-nullable FK with no onDelete). Surface as 409 (resource conflict)
// rather than letting Prisma throw P2003 -> 500. The count check runs
// INSIDE the transaction so a row can't be re-introduced between the
// guard and the project delete (closes the narrow re-intro window).
//
// Only ACTIVE records count: a CANCELLED issue/restored batch or
// CANCELLED reservation is audit-trail-only (cancelIssue() has already
// restored the batch qty; cancelReservation() zeroes remaining_qty).
// Attendance project logs are time-records — they always block, since
// deleting the project would orphan the time record. The user must
// re-assign the attendance log to a different project first.
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
const [activeIssuesCount, activeReservationsCount, attendanceLogCount] =
await Promise.all([
tx.sklad_issues.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
tx.sklad_reservations.count({
where: {
project_id: { in: projectIds },
status: { not: "CANCELLED" },
},
}),
tx.attendance_project_logs.count({
where: { project_id: { in: projectIds } },
}),
]);
if (
activeIssuesCount + activeReservationsCount + attendanceLogCount >
0
) {
throw Object.assign(
new Error(
"Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.",
),
{ status: 409 },
);
}
}
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
}),
]);
if (activeIssuesCount + activeReservationsCount + attendanceLogCount > 0) {
});
await tx.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error:
"Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.",
status: 409,
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
}
throw err;
}
await prisma.$transaction(async (tx) => {
// Clear quotation back-reference (matching PHP)
await tx.quotations.updateMany({
where: { order_id: id },
data: { order_id: null },
});
// Delete linked project and its notes (matching PHP)
if (linkedProjects.length > 0) {
const projectIds = linkedProjects.map((p) => p.id);
await tx.project_notes.deleteMany({
where: { project_id: { in: projectIds } },
});
await tx.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_sections.deleteMany({ where: { order_id: id } });
await tx.orders.delete({ where: { id } });
});
// Best-effort NAS folder cleanup for the order's project(s), outside the
// transaction and only when the user ticked the "delete folder" checkbox in
// the order delete modal. Non-fatal: a NAS error must not undo the

View File

@@ -48,6 +48,29 @@ async function resolveCategoryLabel(key: string): Promise<string> {
return cat?.label ?? fallbackCategoryLabel(key);
}
/**
* Resolve everything an audit description needs (user name, project name,
* category label) for a single mutation in ONE parallel batch instead of two
* sequential awaits (resolvePlanLabels then resolveCategoryLabel). Same three
* queries, but fired together so the audit lookup adds one round-trip, not two.
* The produced description is byte-for-byte identical to the previous code.
*/
async function resolveAuditLabels(
userId: number,
projectId: number | null,
category: string,
): Promise<{
userName: string;
projectName: string | null;
categoryLabel: string;
}> {
const [{ userName, projectName }, categoryLabel] = await Promise.all([
resolvePlanLabels(userId, projectId),
resolveCategoryLabel(category),
]);
return { userName, projectName, categoryLabel };
}
/** Returns an error result if the category key is not an active category. */
async function assertActiveCategory(
key: string,
@@ -129,7 +152,9 @@ export async function resolveCell(
const overrides = await prisma.work_plan_overrides.findMany({
where: { user_id: userId, shift_date: date, is_deleted: false },
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
@@ -156,7 +181,9 @@ export async function resolveCell(
date_to: { gte: date },
is_deleted: false,
},
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
@@ -201,7 +228,9 @@ export async function resolveGrid(
date_from: { lte: dateTo },
date_to: { gte: dateFrom },
},
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { projects: projectSelect },
}),
prisma.work_plan_overrides.findMany({
@@ -210,7 +239,9 @@ export async function resolveGrid(
is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo },
},
orderBy: { created_at: "desc" },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { projects: projectSelect },
}),
]);
@@ -420,8 +451,28 @@ async function assertEntryCapAvailable(
return null;
}
/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
type CreatedEntry = {
id: number;
user_id: number;
date_from: Date;
date_to: Date;
project_id: number | null;
category: string;
note: string;
created_by: number;
created_at: Date | null;
updated_at: Date | null;
is_deleted: boolean | null;
};
/**
* Core create logic shared by the public createEntry and bulkCreateEntries.
* `resolveLabels` controls the audit-description lookup: the single-mutation
* route needs it, but bulkCreateEntries discards the per-entry description (it
* builds its own summary), so it skips the 3 label queries per run entirely —
* removing the per-run audit N+1.
*/
async function createEntryCore(
input: {
user_id: number;
date_from: string;
@@ -432,21 +483,8 @@ export async function createEntry(
},
actorUserId: number,
force: boolean,
): Promise<
Result<{
id: number;
user_id: number;
date_from: Date;
date_to: Date;
project_id: number | null;
category: string;
note: string;
created_by: number;
created_at: Date | null;
updated_at: Date | null;
is_deleted: boolean | null;
}>
> {
resolveLabels: boolean,
): Promise<Result<CreatedEntry>> {
// Range check
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
@@ -480,13 +518,18 @@ export async function createEntry(
},
});
const { userName, projectName } = await resolvePlanLabels(
if (!resolveLabels) {
return { data: created, oldData: null };
}
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
created.user_id,
created.project_id,
created.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(created.category),
categoryLabel,
projectName,
dateFrom: input.date_from,
dateTo: input.date_to,
@@ -496,6 +539,22 @@ export async function createEntry(
return { data: created, oldData: null, description };
}
/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
input: {
user_id: number;
date_from: string;
date_to: string;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
force: boolean,
): Promise<Result<CreatedEntry>> {
return createEntryCore(input, actorUserId, force, true);
}
/** Update a work_plan_entries row. Partial update. */
export async function updateEntry(
id: number,
@@ -546,13 +605,14 @@ export async function updateEntry(
},
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
updated.user_id,
updated.project_id,
updated.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(updated.category),
categoryLabel,
projectName,
dateFrom: isoDay(updated.date_from),
dateTo: isoDay(updated.date_to),
@@ -581,13 +641,14 @@ export async function deleteEntry(
data: { is_deleted: true },
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
existing.user_id,
existing.project_id,
existing.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(existing.category),
categoryLabel,
projectName,
dateFrom: isoDay(existing.date_from),
dateTo: isoDay(existing.date_to),
@@ -660,13 +721,14 @@ export async function createOverride(
throw e;
}
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
created.user_id,
created.project_id,
created.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(created.category),
categoryLabel,
projectName,
dateFrom: input.shift_date,
dateTo: input.shift_date,
@@ -708,13 +770,14 @@ export async function updateOverride(
},
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
updated.user_id,
updated.project_id,
updated.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(updated.category),
categoryLabel,
projectName,
dateFrom: isoDay(updated.shift_date),
dateTo: isoDay(updated.shift_date),
@@ -745,13 +808,14 @@ export async function deleteOverride(
data: { is_deleted: true },
});
const { userName, projectName } = await resolvePlanLabels(
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
existing.user_id,
existing.project_id,
existing.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(existing.category),
categoryLabel,
projectName,
dateFrom: isoDay(existing.shift_date),
dateTo: isoDay(existing.shift_date),
@@ -869,7 +933,10 @@ export async function bulkCreateEntries(
const segFrom = days[runStart].toISOString().slice(0, 10);
const segTo = days[i - 1].toISOString().slice(0, 10);
const runDays = i - runStart;
const res = await createEntry(
// Skip audit-label resolution: bulk builds its own summary and discards
// each entry's description, so resolving labels per run would be pure
// wasted queries (the per-run audit N+1 the audit flagged).
const res = await createEntryCore(
{
user_id: userId,
date_from: segFrom,
@@ -880,6 +947,7 @@ export async function bulkCreateEntries(
},
actorUserId,
false,
false,
);
if ("data" in res) {
createdEntries++;

View File

@@ -4,12 +4,16 @@ export type CatResult<T> = { data: T } | { error: string; status: number };
/** ASCII slug: strip diacritics, lowercase, non-alphanumerics → underscore. */
export function slugifyCategory(label: string): string {
return label
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return (
label
.normalize("NFD")
// U+0300U+036F = Combining Diacritical Marks. Written as Unicode escapes
// (not literal bytes) so the regex is robust to source-file encoding.
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
);
}
export async function listPlanCategories(includeInactive = true) {
@@ -29,17 +33,20 @@ export async function isCategoryKeyActive(key: string): Promise<boolean> {
async function uniqueKey(base: string): Promise<string> {
const root = base || "kategorie";
// Bound the probe loop so a pathological run of collisions can't spin
// indefinitely. After MAX_PROBES suffixes (_2 … _101) fall back to a
// timestamp-suffixed key, which is effectively collision-free.
const MAX_PROBES = 100;
let candidate = root;
let n = 2;
while (
await prisma.plan_categories.findUnique({
for (let n = 2; n < 2 + MAX_PROBES; n++) {
const exists = await prisma.plan_categories.findUnique({
where: { key: candidate },
select: { id: true },
})
) {
candidate = `${root}_${n++}`;
});
if (!exists) return candidate;
candidate = `${root}_${n}`;
}
return candidate;
return `${root}_${Date.now()}`;
}
export async function createPlanCategory(input: {

View File

@@ -140,10 +140,16 @@ export async function updateProject(id: number, body: Record<string, any>) {
existing.project_number &&
nasFileManager.isConfigured()
) {
nasFileManager.renameProjectFolder(
const renamed = nasFileManager.renameProjectFolder(
existing.project_number,
String(body.name || ""),
);
if (!renamed) {
console.error(
"[projects.service] NAS folder not renamed for project",
existing.project_number,
);
}
}
return updated;
@@ -214,12 +220,24 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) {
if (!existing) return { error: "not_found" as const };
if (existing.order_id) return { error: "has_order" as const };
if (deleteFiles && existing.project_number && nasFileManager.isConfigured()) {
await nasFileManager.deleteProjectFolder(existing.project_number);
}
// Delete the DB row FIRST, then the NAS folder (mirrors orders.service
// ordering). If the DB delete fails the folder is left untouched, so a DB
// failure can't orphan the folder; conversely the NAS cleanup is best-effort
// and non-fatal — a NAS error must not undo the committed DB delete.
await prisma.projects.delete({ where: { id } });
if (deleteFiles && existing.project_number && nasFileManager.isConfigured()) {
const ok = await nasFileManager.deleteProjectFolder(
existing.project_number,
);
if (!ok) {
console.error(
"[projects.service] NAS folder not deleted for project",
existing.project_number,
);
}
}
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();

View File

@@ -53,15 +53,26 @@ export async function getSystemSettings(): Promise<SystemSettings> {
let vatRates = DEFAULTS.available_vat_rates;
let currencies = DEFAULTS.available_currencies;
try {
if (row.available_vat_rates) vatRates = JSON.parse(row.available_vat_rates);
if (row.available_vat_rates) {
const parsed = JSON.parse(row.available_vat_rates);
// Shape guard: must be an array of finite numbers, else keep default.
if (Array.isArray(parsed) && parsed.every((n) => Number.isFinite(n))) {
vatRates = parsed;
}
}
} catch {
/* keep default */
/* malformed JSON — keep default (expected-condition catch) */
}
try {
if (row.available_currencies)
currencies = JSON.parse(row.available_currencies);
if (row.available_currencies) {
const parsed = JSON.parse(row.available_currencies);
// Shape guard: must be an array of strings, else keep default.
if (Array.isArray(parsed) && parsed.every((c) => typeof c === "string")) {
currencies = parsed;
}
}
} catch {
/* keep default */
/* malformed JSON — keep default (expected-condition catch) */
}
cache = {

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import bcrypt from "bcryptjs";
import { config } from "../config/env";
@@ -60,7 +61,7 @@ export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
let where: any = search
const where: any = search
? {
OR: [
{ username: { contains: search } },
@@ -113,16 +114,10 @@ export async function createUser(
return { error: "Neplatný formát e-mailu", status: 400 } as const;
}
const existingUsername = await prisma.users.findFirst({
where: { username },
});
if (existingUsername) {
return { error: "Uživatelské jméno již existuje", status: 409 } as const;
}
const existingEmail = await prisma.users.findFirst({ where: { email } });
if (existingEmail) {
return { error: "E-mail již existuje", status: 409 } as const;
// Enforce min password length in-service (consistent with updateUser),
// independent of any schema-level check.
if (data.password.length < 8) {
return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const;
}
if (data.role_id) {
@@ -139,19 +134,60 @@ export async function createUser(
config.security.bcryptCost,
);
const user = await prisma.users.create({
data: {
username,
email,
password_hash: passwordHash,
first_name: firstName,
last_name: lastName,
role_id: data.role_id ? Number(data.role_id) : null,
is_active: data.is_active !== false,
},
});
try {
// Run the uniqueness checks INSIDE the transaction with the create so two
// concurrent creates of the same username/email return the intended 409
// instead of letting the DB unique constraint throw an unhandled 500
// (mirrors updateUser).
const user = await prisma.$transaction(async (tx) => {
const existingUsername = await tx.users.findFirst({
where: { username },
});
if (existingUsername) {
throw Object.assign(new Error("Uživatelské jméno již existuje"), {
status: 409,
});
}
return { user } as const;
const existingEmail = await tx.users.findFirst({ where: { email } });
if (existingEmail) {
throw Object.assign(new Error("E-mail již existuje"), { status: 409 });
}
return tx.users.create({
data: {
username,
email,
password_hash: passwordHash,
first_name: firstName,
last_name: lastName,
role_id: data.role_id ? Number(data.role_id) : null,
is_active: data.is_active !== false,
},
});
});
return { user } as const;
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
}
// A concurrent insert that beat the in-transaction uniqueness check surfaces
// as a P2002 unique-constraint violation — return the intended 409, not 500.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
error: "Uživatelské jméno nebo e-mail již existuje",
status: 409,
} as const;
}
throw err;
}
}
export async function updateUser(

View File

@@ -38,7 +38,35 @@ async function lockParentRow(
): Promise<void> {
// Table name is whitelisted above; no user input. `Prisma.raw` lets us splice
// the identifier into the query while `${id}` parameterizes the value.
await tx.$executeRaw`SELECT id FROM ${Prisma.raw(table)} WHERE id = ${id} FOR UPDATE`;
// Use `$queryRaw` (not `$executeRaw`) for a bare `SELECT … FOR UPDATE`: it
// executes the SELECT and reliably acquires/holds the row lock for the tx —
// the same form attendance.service.lockUserRow uses.
await tx.$queryRaw`SELECT id FROM ${Prisma.raw(table)} WHERE id = ${id} FOR UPDATE`;
}
/**
* Lock a set of child rows (items / batches) FOR UPDATE within the current
* transaction. Always locks in ASCENDING id order — locking in a deterministic
* order across all callers is what prevents deadlocks when two transactions
* touch an overlapping set of rows (e.g. two issues consuming the same batch).
*
* Uses the same SELECT ... FOR UPDATE pattern as `lockParentRow`. Ids are
* de-duplicated and sorted before the lock. A missing id simply matches no row
* (no lock taken); the caller's subsequent findUnique surfaces the 404.
*/
async function lockRowsForUpdate(
tx: TxClient,
table: "sklad_items" | "sklad_batches",
ids: number[],
): Promise<void> {
const uniqueSorted = Array.from(
new Set(ids.filter((id): id is number => typeof id === "number")),
).sort((a, b) => a - b);
for (const id of uniqueSorted) {
// Table name is whitelisted above; no user input. `Prisma.raw` splices the
// identifier; `${id}` is parameterized. `$queryRaw` reliably holds the lock.
await tx.$queryRaw`SELECT id FROM ${Prisma.raw(table)} WHERE id = ${id} FOR UPDATE`;
}
}
// ---------------------------------------------------------------------------
@@ -165,9 +193,13 @@ export async function selectFifoBatches(
tx?: TxClient,
): Promise<Array<{ batchId: number; qty: number }>> {
const client = tx ?? prisma;
// Deterministic FIFO: oldest received first, then by id to break ties when
// two batches share the same received_at (which is truncated to seconds, so
// ties are common in a single transaction). Without the id tie-break the
// order is undefined and FIFO selection is non-reproducible.
const batches = await client.sklad_batches.findMany({
where: { item_id: itemId, is_consumed: false, quantity: { gt: 0 } },
orderBy: { received_at: "asc" },
orderBy: [{ received_at: "asc" }, { id: "asc" }],
select: { id: true, quantity: true },
});
@@ -240,9 +272,25 @@ export async function getBelowMinimumItems() {
},
});
if (items.length === 0) return [];
// Single grouped aggregate over batches instead of one getItemTotalStock()
// call per item (the old N+1). Sum unconsumed batch quantities per item_id
// for exactly the items we care about, then look the totals up by id.
const itemIds = items.map((item) => item.id);
const stockByItem = await prisma.sklad_batches.groupBy({
by: ["item_id"],
where: { item_id: { in: itemIds }, is_consumed: false },
_sum: { quantity: true },
});
const stockMap = new Map<number, number>();
for (const row of stockByItem) {
stockMap.set(row.item_id, Number(row._sum.quantity ?? 0));
}
const results = [];
for (const item of items) {
const totalStock = await getItemTotalStock(item.id);
const totalStock = stockMap.get(item.id) ?? 0;
const minQty = Number(item.min_quantity ?? 0);
if (totalStock < minQty) {
results.push({
@@ -302,11 +350,8 @@ export async function confirmReceipt(
);
for (const item of receipt.items) {
const itemQty = Number(item.quantity);
const linePrice = Number(item.unit_price);
// Create batch for this receipt line
const batch = await tx.sklad_batches.create({
await tx.sklad_batches.create({
data: {
item_id: item.item_id,
receipt_line_id: item.id,
@@ -535,6 +580,23 @@ export async function confirmIssue(
return { error: "Výdej není ve stavu DRAFT", status: 400 };
}
// Lock every affected item AND batch row FOR UPDATE (in ascending id order)
// BEFORE the read-modify-write on batch.quantity below. Without this, two
// issues consuming the same batch could both read the same quantity, both
// pass the validation, and both decrement — a lost update that drives the
// batch quantity negative / double-issues stock. Locking the issue row
// alone does not serialize two DIFFERENT issues that share a batch.
await lockRowsForUpdate(
tx,
"sklad_items",
issue.items.map((item) => item.item_id),
);
await lockRowsForUpdate(
tx,
"sklad_batches",
issue.items.map((item) => item.batch_id),
);
// Validate each line's batch has enough quantity
for (const item of issue.items) {
const batch = await tx.sklad_batches.findUnique({
@@ -546,6 +608,15 @@ export async function confirmIssue(
status: 404,
};
}
// The batch must belong to the same item as the line — a stale/forged
// batch_id pointing at a different item would otherwise decrement the
// wrong item's stock.
if (batch.item_id !== item.item_id) {
return {
error: `Šarže ID ${item.batch_id} nepatří k položce ID ${item.item_id}`,
status: 400,
};
}
if (batch.is_consumed) {
return {
error: `Šarže ID ${item.batch_id} je již spotřebována`,
@@ -696,7 +767,22 @@ export async function cancelIssue(
return { data: { id: issueId } };
}
// CONFIRMED -> CANCELLED: restore everything
// CONFIRMED -> CANCELLED: restore everything.
// Lock the affected item rows then batch rows FOR UPDATE in ascending id
// order — the SAME discipline confirmIssue/confirmInventorySession use — so
// a concurrent confirm consuming a shared batch serialises on the item lock
// first and the two paths cannot deadlock on an inverted batch-lock order.
await lockRowsForUpdate(
tx,
"sklad_items",
issue.items.map((i) => i.item_id),
);
await lockRowsForUpdate(
tx,
"sklad_batches",
issue.items.map((i) => i.batch_id),
);
for (const item of issue.items) {
const itemQty = Number(item.quantity);
@@ -959,32 +1045,44 @@ export async function createReservation(data: {
export async function cancelReservation(
reservationId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
const reservation = await prisma.sklad_reservations.findUnique({
where: { id: reservationId },
return prisma.$transaction(async (tx) => {
// Lock the reservation row FOR UPDATE first, exactly like the other
// confirm/cancel ops lock their parent row. confirmIssue() fulfilling the
// SAME reservation also updates this row (decrements remaining_qty / flips
// it to FULFILLED) — without this lock the two interleave: a concurrent
// confirm could fulfill the reservation between our read and our write,
// and we'd then overwrite a FULFILLED reservation back to CANCELLED with
// remaining_qty:0, losing the fulfillment and double-counting stock.
// The status check below is now authoritative.
await tx.$queryRaw`SELECT id FROM sklad_reservations WHERE id = ${reservationId} FOR UPDATE`;
const reservation = await tx.sklad_reservations.findUnique({
where: { id: reservationId },
});
if (!reservation) {
return { error: "Rezervace nebyla nalezena", status: 404 };
}
if (reservation.status === "CANCELLED") {
return { error: "Rezervace je již zrušena", status: 400 };
}
if (reservation.status === "FULFILLED") {
return { error: "Rezervace je již vyplněna", status: 400 };
}
await tx.sklad_reservations.update({
where: { id: reservationId },
data: {
status: "CANCELLED",
remaining_qty: 0,
modified_at: new Date(),
},
});
return { data: { id: reservationId } };
});
if (!reservation) {
return { error: "Rezervace nebyla nalezena", status: 404 };
}
if (reservation.status === "CANCELLED") {
return { error: "Rezervace je již zrušena", status: 400 };
}
if (reservation.status === "FULFILLED") {
return { error: "Rezervace je již vyplněna", status: 400 };
}
await prisma.sklad_reservations.update({
where: { id: reservationId },
data: {
status: "CANCELLED",
remaining_qty: 0,
modified_at: new Date(),
},
});
return { data: { id: reservationId } };
}
// ---------------------------------------------------------------------------
@@ -1025,12 +1123,29 @@ export async function confirmInventorySession(
return { error: "Inventarizace není ve stavu DRAFT", status: 400 };
}
// Lock every affected item row FOR UPDATE (ascending id order) before the
// read-modify-write on batch quantities / item_locations below. This
// serializes a concurrent confirm/issue touching the same items so they
// can't both create corrective receipts or both FIFO-consume the same
// batches. Affected batches are additionally locked per-item once FIFO
// has selected them (still in ascending id order — see lockRowsForUpdate).
await lockRowsForUpdate(
tx,
"sklad_items",
session.items.map((item) => item.item_id),
);
for (const item of session.items) {
const diff = Number(item.difference);
if (diff === 0) continue;
if (diff > 0) {
// Positive difference: stock surplus -> create corrective receipt
// Positive difference: stock surplus -> create corrective receipt.
// NOTE (known valuation choice, not redesigned here): the surplus batch
// enters at unit_price:0, so a found-surplus carries no stock value.
// This is intentional for now (we don't know the real cost of stock
// that "appeared"); revisit if inventory valuation needs the surplus
// priced at e.g. the item's last/average cost.
const receiptNumber = await generateWarehouseNumber(
"warehouse_receipt",
tx,
@@ -1100,6 +1215,15 @@ export async function confirmInventorySession(
tx,
);
// Lock the selected batch rows FOR UPDATE (ascending id order) before
// decrementing them, so a concurrent issue/inventory consuming the
// same batches serializes behind us and can't lose an update.
await lockRowsForUpdate(
tx,
"sklad_batches",
fifoBatches.map((fb) => fb.batchId),
);
for (const fb of fifoBatches) {
const batch = await tx.sklad_batches.findUnique({
where: { id: fb.batchId },
@@ -1116,7 +1240,14 @@ export async function confirmInventorySession(
});
}
// Decrement item_locations
// Decrement item_locations.
// NOTE (known valuation drift, not redesigned here): for a negative
// difference we consume FIFO batches (which may live at any location)
// but decrement item_locations only for THIS line's location_id by
// the full absDiff. If the consumed batches and the counted location
// diverge, location quantities can drift from batch totals. This is a
// pre-existing valuation/location-tracking limitation; correcting it
// would require per-batch location tracking and is out of scope.
if (item.location_id != null) {
const loc = await tx.sklad_item_locations.findUnique({
where: {