From 77754d0668ed2edc4aabde82bf94c33eb0df2cb9 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Mon, 15 Dec 2025 14:27:53 +0100 Subject: [PATCH] feat: Add daily event notification emails via cron job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add node-cron for scheduled tasks - Create cron/calendar structure with: - email-template.js: HTML email template for event notifications - event-notifier.js: Logic to query tomorrow's events and send emails - index.js: Cron scheduler (runs daily at configurable time) - Send notifications via JMAP using sender email from database - Add admin endpoint POST /api/admin/trigger-notifications for testing - Add env variables: NOTIFICATION_TIME, NOTIFICATION_SENDER_EMAIL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 10 + package.json | 1 + src/controllers/admin.controller.js | 19 ++ src/cron/calendar/email-template.js | 229 ++++++++++++++++++++ src/cron/calendar/event-notifier.js | 318 ++++++++++++++++++++++++++++ src/cron/calendar/index.js | 55 +++++ src/cron/index.js | 17 ++ src/index.js | 4 + src/routes/admin.routes.js | 7 + 9 files changed, 660 insertions(+) create mode 100644 src/cron/calendar/email-template.js create mode 100644 src/cron/calendar/event-notifier.js create mode 100644 src/cron/calendar/index.js create mode 100644 src/cron/index.js diff --git a/package-lock.json b/package-lock.json index 0d78bf7..2f748bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^2.0.2", + "node-cron": "^4.2.1", "pg": "^8.16.3", "uuid": "^13.0.0", "xss-clean": "^0.1.4", @@ -6431,6 +6432,15 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index c27bef8..04296ff 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^2.0.2", + "node-cron": "^4.2.1", "pg": "^8.16.3", "uuid": "^13.0.0", "xss-clean": "^0.1.4", diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js index 7cb01df..311bfdd 100644 --- a/src/controllers/admin.controller.js +++ b/src/controllers/admin.controller.js @@ -1,6 +1,7 @@ import * as adminService from '../services/admin.service.js'; import * as statusService from '../services/status.service.js'; import { logUserCreation, logRoleChange } from '../services/audit.service.js'; +import { triggerEventNotifications } from '../cron/index.js'; /** * Vytvorenie nového usera s automatic temporary password (admin only) @@ -160,3 +161,21 @@ export const getServerStatus = async (req, res, next) => { next(error); } }; + +/** + * Manually trigger event notifications (admin only, for testing) + * POST /api/admin/trigger-notifications + */ +export const triggerNotifications = async (req, res, next) => { + try { + const stats = await triggerEventNotifications(); + + res.status(200).json({ + success: true, + data: stats, + message: `Notifikácie odoslané: ${stats.sent}, neúspešné: ${stats.failed}, preskočené: ${stats.skipped}`, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/cron/calendar/email-template.js b/src/cron/calendar/email-template.js new file mode 100644 index 0000000..773787c --- /dev/null +++ b/src/cron/calendar/email-template.js @@ -0,0 +1,229 @@ +/** + * HTML Email Template for Event Notifications + */ + +/** + * Format date to Slovak locale + * @param {Date} date + * @returns {string} + */ +const formatDate = (date) => { + return new Intl.DateTimeFormat('sk-SK', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(date)); +}; + +/** + * Format time to Slovak locale + * @param {Date} date + * @returns {string} + */ +const formatTime = (date) => { + return new Intl.DateTimeFormat('sk-SK', { + hour: '2-digit', + minute: '2-digit', + }).format(new Date(date)); +}; + +/** + * Get event type label in Slovak + * @param {string} type + * @returns {string} + */ +const getTypeLabel = (type) => { + return type === 'meeting' ? 'Stretnutie' : 'Udalosť'; +}; + +/** + * Get badge color based on event type + * @param {string} type + * @returns {string} + */ +const getTypeBadgeColor = (type) => { + return type === 'meeting' ? '#22c55e' : '#3b82f6'; +}; + +/** + * Generate HTML email template for event notification + * @param {Object} params + * @param {string} params.firstName - User's first name + * @param {string} params.username - User's username (fallback) + * @param {Object} params.event - Event object + * @param {string} params.event.title - Event title + * @param {string} params.event.description - Event description + * @param {string} params.event.type - Event type ('meeting' or 'event') + * @param {Date} params.event.start - Event start time + * @param {Date} params.event.end - Event end time + * @returns {string} HTML email content + */ +export const generateEventNotificationHtml = ({ firstName, username, event }) => { + const displayName = firstName || username || 'Používateľ'; + const typeLabel = getTypeLabel(event.type); + const badgeColor = getTypeBadgeColor(event.type); + + return ` + + + + + + Pripomienka udalosti - CRM + + + + + + +
+ + + + + + + + + + + + + + + + + +
+
+ CRM +
+

+ Pripomienka udalosti +

+
+

+ Ahoj ${displayName}, +

+ +

+ Zajtra máš naplánovanú udalosť v kalendári: +

+ + + + + + +
+ +
+ + ${typeLabel} + +
+ + +

+ ${event.title} +

+ + ${event.description ? ` + +

+ ${event.description} +

+ ` : ''} + + + + + + + + + +
+ + + + + +
+ 📅 + + + ${formatDate(event.start)} + +
+
+ + + + + +
+ 🕐 + + + ${formatTime(event.start)} - ${formatTime(event.end)} + +
+
+
+ +

+ Prajeme ti produktívny deň! +

+
+

+ Táto správa bola automaticky vygenerovaná systémom CRM.
+ Prosím, neodpovedajte na tento email. +

+
+
+ + +`.trim(); +}; + +/** + * Generate plain text version of the email + * @param {Object} params + * @param {string} params.firstName - User's first name + * @param {string} params.username - User's username (fallback) + * @param {Object} params.event - Event object + * @returns {string} Plain text email content + */ +export const generateEventNotificationText = ({ firstName, username, event }) => { + const displayName = firstName || username || 'Používateľ'; + const typeLabel = getTypeLabel(event.type); + + return ` +Ahoj ${displayName}, + +Zajtra máš naplánovanú udalosť v kalendári: + +${typeLabel.toUpperCase()}: ${event.title} +${event.description ? `Popis: ${event.description}\n` : ''} +Dátum: ${formatDate(event.start)} +Čas: ${formatTime(event.start)} - ${formatTime(event.end)} + +Prajeme ti produktívny deň! + +--- +Táto správa bola automaticky vygenerovaná systémom CRM. +Prosím, neodpovedajte na tento email. +`.trim(); +}; + +/** + * Generate email subject + * @param {Object} event + * @returns {string} + */ +export const generateEventNotificationSubject = (event) => { + const typeLabel = getTypeLabel(event.type); + return `Pripomienka: ${typeLabel} - ${event.title} (zajtra)`; +}; diff --git a/src/cron/calendar/event-notifier.js b/src/cron/calendar/event-notifier.js new file mode 100644 index 0000000..5aea55b --- /dev/null +++ b/src/cron/calendar/event-notifier.js @@ -0,0 +1,318 @@ +import { eq, and, gte, lt } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { events, eventUsers, users, emailAccounts, userEmailAccounts } from '../../db/schema.js'; +import { decryptPassword } from '../../utils/password.js'; +import { jmapRequest, getMailboxes, getIdentities } from '../../services/jmap/client.js'; +import { logger } from '../../utils/logger.js'; +import { + generateEventNotificationHtml, + generateEventNotificationText, + generateEventNotificationSubject, +} from './email-template.js'; + +/** + * Get sender email account credentials from database + * @returns {Promise} Sender account with decrypted password + */ +const getSenderAccount = async () => { + const senderEmail = process.env.NOTIFICATION_SENDER_EMAIL; + + if (!senderEmail) { + logger.error('NOTIFICATION_SENDER_EMAIL nie je nastavený v .env'); + return null; + } + + const [account] = await db + .select() + .from(emailAccounts) + .where(eq(emailAccounts.email, senderEmail)) + .limit(1); + + if (!account) { + logger.error(`Email účet ${senderEmail} nebol nájdený v databáze`); + return null; + } + + try { + const decryptedPassword = decryptPassword(account.emailPassword); + return { + id: account.id, + email: account.email, + password: decryptedPassword, + jmapAccountId: account.jmapAccountId, + }; + } catch (error) { + logger.error(`Nepodarilo sa dešifrovať heslo pre ${senderEmail}`, error); + return null; + } +}; + +/** + * Get tomorrow's date range (start of day to end of day) + * @returns {{ startOfTomorrow: Date, endOfTomorrow: Date }} + */ +const getTomorrowRange = () => { + const now = new Date(); + + // Start of tomorrow (00:00:00) + const startOfTomorrow = new Date(now); + startOfTomorrow.setDate(startOfTomorrow.getDate() + 1); + startOfTomorrow.setHours(0, 0, 0, 0); + + // End of tomorrow (23:59:59.999) + const endOfTomorrow = new Date(startOfTomorrow); + endOfTomorrow.setHours(23, 59, 59, 999); + + return { startOfTomorrow, endOfTomorrow }; +}; + +/** + * Get events starting tomorrow with assigned users + * @returns {Promise} Events with user info + */ +const getTomorrowEvents = async () => { + const { startOfTomorrow, endOfTomorrow } = getTomorrowRange(); + + logger.info(`Hľadám udalosti od ${startOfTomorrow.toISOString()} do ${endOfTomorrow.toISOString()}`); + + // Get events starting tomorrow + const tomorrowEvents = await db + .select({ + eventId: events.id, + title: events.title, + description: events.description, + type: events.type, + start: events.start, + end: events.end, + userId: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(events) + .innerJoin(eventUsers, eq(events.id, eventUsers.eventId)) + .innerJoin(users, eq(eventUsers.userId, users.id)) + .where( + and( + gte(events.start, startOfTomorrow), + lt(events.start, endOfTomorrow) + ) + ); + + return tomorrowEvents; +}; + +/** + * Get user's primary email address + * @param {string} userId + * @returns {Promise} + */ +const getUserEmail = async (userId) => { + const [result] = await db + .select({ + email: emailAccounts.email, + }) + .from(userEmailAccounts) + .innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id)) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.isPrimary, true) + ) + ) + .limit(1); + + return result?.email || null; +}; + +/** + * Send email via JMAP (simplified version for notifications) + * @param {Object} jmapConfig - JMAP configuration + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} htmlBody - HTML body + * @param {string} textBody - Plain text body + * @returns {Promise} Success status + */ +const sendNotificationEmail = async (jmapConfig, to, subject, htmlBody, textBody) => { + try { + // Get mailboxes + const mailboxes = await getMailboxes(jmapConfig); + const sentMailbox = mailboxes.find((m) => m.role === 'sent' || m.name === 'Sent'); + + if (!sentMailbox) { + logger.error('Priečinok Odoslané nebol nájdený'); + return false; + } + + // Create email with HTML body + const createResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + create: { + draft: { + mailboxIds: { + [sentMailbox.id]: true, + }, + keywords: { + $draft: true, + }, + from: [{ email: jmapConfig.username }], + to: [{ email: to }], + subject: subject, + htmlBody: [{ partId: 'html', type: 'text/html' }], + textBody: [{ partId: 'text', type: 'text/plain' }], + bodyValues: { + html: { value: htmlBody }, + text: { value: textBody }, + }, + }, + }, + }, + 'set1', + ], + ]); + + const createdEmailId = createResponse.methodResponses[0][1].created?.draft?.id; + + if (!createdEmailId) { + logger.error('Nepodarilo sa vytvoriť koncept emailu', createResponse.methodResponses[0][1].notCreated); + return false; + } + + // Get identity for sending + const identities = await getIdentities(jmapConfig); + const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0]; + + if (!identity) { + logger.error('Nenašla sa identita pre odosielanie emailov'); + return false; + } + + // Submit the email + const submitResponse = await jmapRequest(jmapConfig, [ + [ + 'EmailSubmission/set', + { + accountId: jmapConfig.accountId, + create: { + submission: { + emailId: createdEmailId, + identityId: identity.id, + }, + }, + }, + 'submit1', + ], + ]); + + const submissionId = submitResponse.methodResponses[0][1].created?.submission?.id; + + if (!submissionId) { + logger.error('Nepodarilo sa odoslať email', submitResponse.methodResponses[0][1].notCreated); + return false; + } + + return true; + } catch (error) { + logger.error(`Chyba pri odosielaní emailu na ${to}`, error); + return false; + } +}; + +/** + * Main function to send event notifications + * @returns {Promise<{ sent: number, failed: number, skipped: number }>} + */ +export const sendEventNotifications = async () => { + logger.info('=== Spúšťam kontrolu zajtrajších udalostí ==='); + + const stats = { sent: 0, failed: 0, skipped: 0 }; + + // Get sender account + const senderAccount = await getSenderAccount(); + if (!senderAccount) { + logger.error('Nemôžem pokračovať bez platného odosielacieho účtu'); + return stats; + } + + const jmapConfig = { + server: process.env.JMAP_SERVER, + username: senderAccount.email, + password: senderAccount.password, + accountId: senderAccount.jmapAccountId, + }; + + // Get tomorrow's events with assigned users + const tomorrowEvents = await getTomorrowEvents(); + + if (tomorrowEvents.length === 0) { + logger.info('Žiadne udalosti na zajtra'); + return stats; + } + + logger.info(`Nájdených ${tomorrowEvents.length} priradení udalostí na zajtra`); + + // Group events by user to avoid duplicate notifications for same event + const userNotifications = new Map(); + + for (const row of tomorrowEvents) { + const key = `${row.userId}-${row.eventId}`; + if (!userNotifications.has(key)) { + userNotifications.set(key, { + userId: row.userId, + username: row.username, + firstName: row.firstName, + lastName: row.lastName, + event: { + id: row.eventId, + title: row.title, + description: row.description, + type: row.type, + start: row.start, + end: row.end, + }, + }); + } + } + + logger.info(`Unikátnych notifikácií na odoslanie: ${userNotifications.size}`); + + // Send notifications + for (const [key, data] of userNotifications) { + const { userId, username, firstName, event } = data; + + // Get user's email + const userEmail = await getUserEmail(userId); + + if (!userEmail) { + logger.warn(`Používateľ ${username} nemá nastavený primárny email - preskakujem`); + stats.skipped++; + continue; + } + + // Generate email content + const subject = generateEventNotificationSubject(event); + const htmlBody = generateEventNotificationHtml({ firstName, username, event }); + const textBody = generateEventNotificationText({ firstName, username, event }); + + // Send email + logger.info(`Odosielam notifikáciu pre ${username} (${userEmail}) - udalosť: ${event.title}`); + + const success = await sendNotificationEmail(jmapConfig, userEmail, subject, htmlBody, textBody); + + if (success) { + logger.success(`Email úspešne odoslaný na ${userEmail}`); + stats.sent++; + } else { + logger.error(`Nepodarilo sa odoslať email na ${userEmail}`); + stats.failed++; + } + } + + logger.info(`=== Hotovo: odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`); + + return stats; +}; diff --git a/src/cron/calendar/index.js b/src/cron/calendar/index.js new file mode 100644 index 0000000..0a466b4 --- /dev/null +++ b/src/cron/calendar/index.js @@ -0,0 +1,55 @@ +import cron from 'node-cron'; +import { logger } from '../../utils/logger.js'; +import { sendEventNotifications } from './event-notifier.js'; + +/** + * Parse NOTIFICATION_TIME from env (format: "HH:mm") + * @returns {{ hour: string, minute: string }} + */ +const parseNotificationTime = () => { + const time = process.env.NOTIFICATION_TIME || '07:00'; + const [hour, minute] = time.split(':'); + + return { + hour: hour || '7', + minute: minute || '0', + }; +}; + +/** + * Start the calendar notification cron job + */ +export const startCalendarNotificationCron = () => { + const { hour, minute } = parseNotificationTime(); + + // Cron expression: minute hour * * * (every day at specified time) + const cronExpression = `${minute} ${hour} * * *`; + + logger.info(`Nastavujem cron pre kalendárne notifikácie: ${cronExpression} (${hour}:${minute.padStart(2, '0')} každý deň)`); + + const task = cron.schedule(cronExpression, async () => { + logger.info('Cron job spustený - kontrolujem zajtrajšie udalosti'); + + try { + const stats = await sendEventNotifications(); + logger.info(`Cron job dokončený - výsledky: ${JSON.stringify(stats)}`); + } catch (error) { + logger.error('Chyba pri spúšťaní cron jobu', error); + } + }, { + scheduled: true, + timezone: 'Europe/Bratislava', + }); + + logger.success(`Kalendárny notifikačný cron naplánovaný na ${hour}:${minute.padStart(2, '0')} (Europe/Bratislava)`); + + return task; +}; + +/** + * Manually trigger event notifications (for testing) + */ +export const triggerEventNotifications = async () => { + logger.info('Manuálne spúšťam kontrolu notifikácií...'); + return sendEventNotifications(); +}; diff --git a/src/cron/index.js b/src/cron/index.js new file mode 100644 index 0000000..ff4585e --- /dev/null +++ b/src/cron/index.js @@ -0,0 +1,17 @@ +import { logger } from '../utils/logger.js'; +import { startCalendarNotificationCron, triggerEventNotifications } from './calendar/index.js'; + +/** + * Start all cron jobs + */ +export const startAllCronJobs = () => { + logger.info('=== Inicializujem cron jobs ==='); + + // Calendar event notifications + startCalendarNotificationCron(); + + logger.info('=== Všetky cron jobs inicializované ==='); +}; + +// Export individual functions for testing/manual triggers +export { triggerEventNotifications }; diff --git a/src/index.js b/src/index.js index 151eddb..54b32ec 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,10 @@ import app from './app.js'; +import { startAllCronJobs } from './cron/index.js'; const port = process.env.PORT || 5000; app.listen(port, () => { console.log(`🚀 Server running on http://localhost:${port}`); + + // Start cron jobs after server is running + startAllCronJobs(); }); diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index cdb1903..80ca255 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -57,4 +57,11 @@ router.delete( // Server status (CPU, RAM, Disk, Network, Uptime) router.get('/server-status', adminController.getServerStatus); +/** + * Notifications + */ + +// Manually trigger event notifications (for testing) +router.post('/trigger-notifications', adminController.triggerNotifications); + export default router;