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:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user