feat: Add daily event notification emails via cron job
- 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 <noreply@anthropic.com>
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
229
src/cron/calendar/email-template.js
Normal file
229
src/cron/calendar/email-template.js
Normal file
@@ -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 `
|
||||
<!DOCTYPE html>
|
||||
<html lang="sk">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pripomienka udalosti - CRM</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f3f4f6;">
|
||||
<tr>
|
||||
<td style="padding: 40px 20px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 32px 32px 24px 32px; text-align: center; border-bottom: 1px solid #e5e7eb;">
|
||||
<div style="display: inline-block; padding: 12px 20px; background-color: #1e293b; border-radius: 8px;">
|
||||
<span style="font-size: 24px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">CRM</span>
|
||||
</div>
|
||||
<h1 style="margin: 20px 0 0 0; font-size: 22px; font-weight: 600; color: #1f2937;">
|
||||
Pripomienka udalosti
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 32px;">
|
||||
<p style="margin: 0 0 24px 0; font-size: 16px; color: #374151; line-height: 1.6;">
|
||||
Ahoj <strong>${displayName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 24px 0; font-size: 16px; color: #374151; line-height: 1.6;">
|
||||
Zajtra máš naplánovanú udalosť v kalendári:
|
||||
</p>
|
||||
|
||||
<!-- Event Card -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
|
||||
<tr>
|
||||
<td style="padding: 24px;">
|
||||
<!-- Type Badge -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<span style="display: inline-block; padding: 4px 12px; background-color: ${badgeColor}; color: #ffffff; font-size: 12px; font-weight: 600; border-radius: 9999px; text-transform: uppercase;">
|
||||
${typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 600; color: #1f2937;">
|
||||
${event.title}
|
||||
</h2>
|
||||
|
||||
${event.description ? `
|
||||
<!-- Description -->
|
||||
<p style="margin: 0 0 16px 0; font-size: 14px; color: #6b7280; line-height: 1.5;">
|
||||
${event.description}
|
||||
</p>
|
||||
` : ''}
|
||||
|
||||
<!-- Date & Time -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 8px 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="width: 24px; vertical-align: top;">
|
||||
<span style="font-size: 16px;">📅</span>
|
||||
</td>
|
||||
<td style="padding-left: 8px;">
|
||||
<span style="font-size: 14px; color: #374151; font-weight: 500;">
|
||||
${formatDate(event.start)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="width: 24px; vertical-align: top;">
|
||||
<span style="font-size: 16px;">🕐</span>
|
||||
</td>
|
||||
<td style="padding-left: 8px;">
|
||||
<span style="font-size: 14px; color: #374151; font-weight: 500;">
|
||||
${formatTime(event.start)} - ${formatTime(event.end)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 24px 0 0 0; font-size: 14px; color: #6b7280; line-height: 1.6;">
|
||||
Prajeme ti produktívny deň!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 24px 32px; background-color: #f8fafc; border-top: 1px solid #e5e7eb; border-radius: 0 0 12px 12px;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center; line-height: 1.5;">
|
||||
Táto správa bola automaticky vygenerovaná systémom CRM.<br>
|
||||
Prosím, neodpovedajte na tento email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.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)`;
|
||||
};
|
||||
318
src/cron/calendar/event-notifier.js
Normal file
318
src/cron/calendar/event-notifier.js
Normal file
@@ -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<Object|null>} 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<Array>} 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<string|null>}
|
||||
*/
|
||||
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<boolean>} 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;
|
||||
};
|
||||
55
src/cron/calendar/index.js
Normal file
55
src/cron/calendar/index.js
Normal file
@@ -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();
|
||||
};
|
||||
17
src/cron/index.js
Normal file
17
src/cron/index.js
Normal file
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user