diff --git a/.env.example b/.env.example index f8e0988..ebe1988 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/config/env.ts b/src/config/env.ts index d9def2e..4ed7b90 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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), }, diff --git a/src/routes/admin/leave-requests.ts b/src/routes/admin/leave-requests.ts index c0c55f0..caabca8 100644 --- a/src/routes/admin/leave-requests.ts +++ b/src/routes/admin/leave-requests.ts @@ -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í'); }); diff --git a/src/services/leave-notification.ts b/src/services/leave-notification.ts new file mode 100644 index 0000000..e5a2e18 --- /dev/null +++ b/src/services/leave-notification.ts @@ -0,0 +1,96 @@ +import { sendMail } from './mailer'; +import { config } from '../config/env'; + +const LEAVE_TYPE_LABELS: Record = { + 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, '"'); +} + +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 { + 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řejít ke schvalování + +

` + : ''; + + 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 = ` + + +

Nová žádost o nepřítomnost

+ + + + + + + + + + + + + + + + + + ${notes ? ` + + + ` : ''} +
Zaměstnanec:${escapeHtml(employeeName)}
Typ:${escapeHtml(leaveType)}
Období:${dateFrom} – ${dateTo}
Pracovní dny:${request.total_days} dní (${request.total_hours} hodin)
Poznámka:${escapeHtml(notes)}
+ ${approvalLink} +
+

+ Tato zpráva byla automaticky vygenerována systémem.
+ Datum: ${timestamp} +

+ + `; + + const sent = await sendMail(notifyEmail, subject, html); + if (!sent) { + console.error(`LeaveNotification: Failed to send notification to ${notifyEmail}`); + } +} diff --git a/src/services/mailer.ts b/src/services/mailer.ts new file mode 100644 index 0000000..21c0e90 --- /dev/null +++ b/src/services/mailer.ts @@ -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 { + 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; + } +}