Files
app/src/services/leave-notification.ts
BOHA 519edce373 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>
2026-06-09 06:45:26 +02:00

125 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 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}`,
);
}
}