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>
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import { sendMail } from "./mailer";
|
||
import { config } from "../config/env";
|
||
import { localDateCzStr, localDateTimeCzStr } from "../utils/date";
|
||
import { getSystemSettings } from "./system-settings";
|
||
|
||
const LEAVE_TYPE_LABELS: Record<string, string> = {
|
||
vacation: "Dovolená",
|
||
sick: "Nemocenská",
|
||
unpaid: "Neplacené volno",
|
||
};
|
||
|
||
function formatDate(dateStr: string): string {
|
||
// `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 {
|
||
return str
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
// Strip characters that could break out of the email Subject: header
|
||
// (CRLF, NUL) or otherwise pollute the envelope. Without this, a user
|
||
// 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 {
|
||
|
||
return str
|
||
.replace(/[\r\n\t\0]/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
interface LeaveRequestData {
|
||
leave_type: string;
|
||
date_from: string;
|
||
date_to: string;
|
||
total_days: number;
|
||
total_hours: number;
|
||
notes?: string | null;
|
||
}
|
||
|
||
export async function notifyNewLeaveRequest(
|
||
request: LeaveRequestData,
|
||
employeeName: string,
|
||
): Promise<void> {
|
||
const settings = await getSystemSettings();
|
||
const notifyEmail = settings.leave_notify_email || config.email.leaveNotify;
|
||
if (!notifyEmail) return;
|
||
|
||
const leaveType = LEAVE_TYPE_LABELS[request.leave_type] || request.leave_type;
|
||
const dateFrom = formatDate(request.date_from);
|
||
const dateTo = formatDate(request.date_to);
|
||
const notes = request.notes || "";
|
||
|
||
const subject = `Nová žádost o nepřítomnost - ${sanitizeHeaderValue(employeeName)} (${sanitizeHeaderValue(leaveType)})`;
|
||
|
||
const appUrl = config.appUrl || "";
|
||
const approvalLink = appUrl
|
||
? `<p style="margin-top: 20px;">
|
||
<a href="${escapeHtml(appUrl)}/leave-approval"
|
||
style="background: #de3a3a; color: #fff; padding: 10px 20px;
|
||
text-decoration: none; border-radius: 5px;">
|
||
Přejít ke schvalování
|
||
</a>
|
||
</p>`
|
||
: "";
|
||
|
||
const now = new Date();
|
||
const timestamp = localDateTimeCzStr(now);
|
||
|
||
const html = `
|
||
<html>
|
||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||
<h2 style="color: #de3a3a;">Nová žádost o nepřítomnost</h2>
|
||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||
<tr>
|
||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold; width: 180px;">Zaměstnanec:</td>
|
||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${escapeHtml(employeeName)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold;">Typ:</td>
|
||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${escapeHtml(leaveType)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold;">Období:</td>
|
||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${dateFrom} – ${dateTo}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold;">Pracovní dny:</td>
|
||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${request.total_days} dní (${request.total_hours} hodin)</td>
|
||
</tr>
|
||
${
|
||
notes
|
||
? `<tr>
|
||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold;">Poznámka:</td>
|
||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${escapeHtml(notes)}</td>
|
||
</tr>`
|
||
: ""
|
||
}
|
||
</table>
|
||
${approvalLink}
|
||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||
<p style="font-size: 12px; color: #999;">
|
||
Tato zpráva byla automaticky vygenerována systémem.<br>
|
||
Datum: ${timestamp}
|
||
</p>
|
||
</body>
|
||
</html>`;
|
||
|
||
const sent = await sendMail(notifyEmail, subject, html);
|
||
if (!sent) {
|
||
console.error(
|
||
`LeaveNotification: Failed to send notification to ${notifyEmail}`,
|
||
);
|
||
}
|
||
}
|