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:
@@ -23,6 +23,10 @@ MAX_UPLOAD_SIZE=52428800
|
|||||||
CONTACT_EMAIL_TO=
|
CONTACT_EMAIL_TO=
|
||||||
CONTACT_EMAIL_FROM=
|
CONTACT_EMAIL_FROM=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
LEAVE_NOTIFY_EMAIL=
|
||||||
|
|
||||||
|
# App URL (for email links)
|
||||||
|
APP_URL=
|
||||||
|
|
||||||
# CORS (production only, comma-separated)
|
# CORS (production only, comma-separated)
|
||||||
CORS_ORIGINS=
|
CORS_ORIGINS=
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ export const config = {
|
|||||||
contactTo: process.env.CONTACT_EMAIL_TO || '',
|
contactTo: process.env.CONTACT_EMAIL_TO || '',
|
||||||
contactFrom: process.env.CONTACT_EMAIL_FROM || '',
|
contactFrom: process.env.CONTACT_EMAIL_FROM || '',
|
||||||
smtpFrom: process.env.SMTP_FROM || '',
|
smtpFrom: process.env.SMTP_FROM || '',
|
||||||
|
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
appUrl: process.env.APP_URL || '',
|
||||||
|
|
||||||
cors: {
|
cors: {
|
||||||
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
|
origins: (process.env.CORS_ORIGINS || '').split(',').filter(Boolean),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { success, error, parseId } from '../../utils/response';
|
|||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema } from '../../schemas/leave-requests.schema';
|
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_LEAVE_TYPES = ['vacation', 'sick', 'unpaid'] as const;
|
||||||
const VALID_REVIEW_STATUSES = ['approved', 'rejected'] 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` });
|
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í');
|
return success(reply, { id: leaveRequest.id }, 201, 'Žádost byla odeslána ke schválení');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
96
src/services/leave-notification.ts
Normal file
96
src/services/leave-notification.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
25
src/services/mailer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user