feat: add email notification for new leave requests

- mailer.ts: nodemailer transport via local sendmail
- leave-notification.ts: HTML email matching PHP template
- Sends notification to LEAVE_NOTIFY_EMAIL on new leave request
- Non-blocking: errors logged but don't fail the request
- Added LEAVE_NOTIFY_EMAIL and APP_URL env vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 11:54:29 +01:00
parent 0baa604ade
commit 8a453deaac
5 changed files with 145 additions and 0 deletions

View File

@@ -23,6 +23,10 @@ MAX_UPLOAD_SIZE=52428800
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
# App URL (for email links)
APP_URL=
# CORS (production only, comma-separated)
CORS_ORIGINS=

View File

@@ -37,8 +37,11 @@ export const config = {
contactTo: process.env.CONTACT_EMAIL_TO || '',
contactFrom: process.env.CONTACT_EMAIL_FROM || '',
smtpFrom: process.env.SMTP_FROM || '',
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || '',
},
appUrl: process.env.APP_URL || '',
cors: {
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
},

View File

@@ -7,6 +7,7 @@ import { success, error, parseId } from '../../utils/response';
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
import { parseBody } from '../../schemas/common';
import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema } from '../../schemas/leave-requests.schema';
import { notifyNewLeaveRequest } from '../../services/leave-notification';
const VALID_LEAVE_TYPES = ['vacation', 'sick', 'unpaid'] as const;
const VALID_REVIEW_STATUSES = ['approved', 'rejected'] as const;
@@ -85,6 +86,22 @@ export default async function leaveRequestsRoutes(fastify: FastifyInstance): Pro
});
await logAudit({ request, authData, action: 'create', entityType: 'leave_request', entityId: leaveRequest.id, description: `Vytvořena žádost o nepřítomnost` });
// Send email notification (non-blocking)
try {
const employeeName = `${authData.firstName} ${authData.lastName}`.trim() || authData.username;
notifyNewLeaveRequest({
leave_type: leaveType,
date_from: body.date_from,
date_to: body.date_to,
total_days: businessDays,
total_hours: businessDays * 8,
notes: body.notes,
}, employeeName).catch(err => request.log.error(err, 'Leave notification error'));
} catch (err) {
request.log.error(err, 'Leave notification error');
}
return success(reply, { id: leaveRequest.id }, 201, 'Žádost byla odeslána ke schválení');
});

View File

@@ -0,0 +1,96 @@
import { sendMail } from './mailer';
import { config } from '../config/env';
const LEAVE_TYPE_LABELS: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemocenská',
unpaid: 'Neplacené volno',
};
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
} catch {
return dateStr;
}
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 notifyEmail = 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 - ${employeeName} (${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 = `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
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}`);
}
}

25
src/services/mailer.ts Normal file
View File

@@ -0,0 +1,25 @@
import nodemailer from 'nodemailer';
import { config } from '../config/env';
const transporter = nodemailer.createTransport({
sendmail: true,
newline: 'unix',
path: '/usr/sbin/sendmail',
});
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
const from = config.email.smtpFrom || config.email.contactFrom || 'web@boha-automation.cz';
try {
await transporter.sendMail({
from: `System <${from}>`,
to,
subject,
html,
});
return true;
} catch (err) {
console.error(`Mailer error: failed to send to ${to}:`, err);
return false;
}
}