diff --git a/src/cron/calendar/event-notifier.js b/src/cron/calendar/event-notifier.js index e22d1fe..0ad5f00 100644 --- a/src/cron/calendar/event-notifier.js +++ b/src/cron/calendar/event-notifier.js @@ -10,10 +10,8 @@ import { generateEventNotificationSubject, } from './email-template.js'; -/** - * Get sender email account credentials from database - * @returns {Promise} Sender account with decrypted password - */ +// --- Private helpers --- + const getSenderAccount = async () => { const senderEmail = process.env.NOTIFICATION_SENDER_EMAIL; @@ -47,239 +45,6 @@ const getSenderAccount = async () => { } }; -/** - * 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 range for events starting in the next hour - * @returns {{ startOfRange: Date, endOfRange: Date }} - */ -const getOneHourRange = () => { - const now = new Date(); - - // Start of range: now - const startOfRange = new Date(now); - - // End of range: 1 hour from now - const endOfRange = new Date(now); - endOfRange.setHours(endOfRange.getHours() + 1); - - return { startOfRange, endOfRange }; -}; - -/** - * 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 events starting in the next hour with assigned users - * @returns {Promise} Events with user info - */ -const getUpcomingEvents = async () => { - const { startOfRange, endOfRange } = getOneHourRange(); - - logger.info(`Hľadám udalosti začínajúce v najbližšej hodine: ${startOfRange.toISOString()} - ${endOfRange.toISOString()}`); - - // Get events starting in the next hour - const upcomingEvents = 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, startOfRange), - lt(events.start, endOfRange) - ) - ); - - return upcomingEvents; -}; - -/** - * 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; - } -}; - -/** - * Get sender account by user ID (admin's primary email) - * @param {string} userId - Admin user ID - * @returns {Promise} Sender account with decrypted password - */ const getSenderAccountByUserId = async (userId) => { const [result] = await db .select({ @@ -317,33 +82,217 @@ const getSenderAccountByUserId = async (userId) => { } }; +const getEventsInRange = async (start, end) => { + logger.info(`Hľadám udalosti od ${start.toISOString()} do ${end.toISOString()}`); + + return 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, start), + lt(events.start, end) + ) + ); +}; + +const groupEventsByUser = (eventRows) => { + const userNotifications = new Map(); + + for (const row of eventRows) { + 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, + }, + }); + } + } + + return userNotifications; +}; + +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; +}; + +const sendNotificationEmail = async (jmapConfig, to, subject, htmlBody, textBody) => { + try { + 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; + } + + 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; + } + + 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; + } + + 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; + } +}; + +const sendNotificationsToUsers = async (jmapConfig, userNotifications, { getSubject, logPrefix }) => { + const stats = { sent: 0, failed: 0, skipped: 0 }; + + for (const [, data] of userNotifications) { + const { userId, username, firstName, event } = data; + + const userEmail = await getUserEmail(userId); + + if (!userEmail) { + stats.skipped++; + continue; + } + + const subject = getSubject(event); + const htmlBody = generateEventNotificationHtml({ firstName, username, event }); + const textBody = generateEventNotificationText({ firstName, username, event }); + + logger.info(`Odosielam ${logPrefix}notifikáciu pre ${username} - 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++; + } + } + + return stats; +}; + +const buildJmapConfig = (senderAccount) => ({ + server: process.env.JMAP_SERVER, + username: senderAccount.email, + password: senderAccount.password, + accountId: senderAccount.jmapAccountId, +}); + +// --- Public API --- + /** * Send notification for a single event to all assigned users - * @param {string} eventId - Event ID - * @param {string} adminUserId - Admin user ID (sender) - * @returns {Promise<{ sent: number, failed: number, skipped: number, eventTitle: string }>} */ export const sendSingleEventNotification = async (eventId, adminUserId) => { logger.info(`=== Odosielam notifikácie pre event ${eventId} od admina ${adminUserId} ===`); - const stats = { sent: 0, failed: 0, skipped: 0, eventTitle: '' }; - - // Get admin's primary email account const senderAccount = await getSenderAccountByUserId(adminUserId); if (!senderAccount) { throw new Error('Nemáte nastavený primárny email účet. Nastavte ho v profile.'); } - const jmapConfig = { - server: process.env.JMAP_SERVER, - username: senderAccount.email, - password: senderAccount.password, - accountId: senderAccount.jmapAccountId, - }; - + const jmapConfig = buildJmapConfig(senderAccount); logger.info(`Odosielam z účtu: ${senderAccount.email}`); - // Get the event with assigned users const eventData = await db .select({ eventId: events.id, @@ -366,141 +315,58 @@ export const sendSingleEventNotification = async (eventId, adminUserId) => { throw new Error('Event nebol nájdený alebo nemá priradených používateľov'); } - stats.eventTitle = eventData[0].title; - logger.info(`Event: ${stats.eventTitle}, priradených používateľov: ${eventData.length}`); + const eventTitle = eventData[0].title; + logger.info(`Event: ${eventTitle}, priradených používateľov: ${eventData.length}`); - // Send notifications to each assigned user - for (const row of eventData) { - const { userId, username, firstName } = row; - const event = { - id: row.eventId, - title: row.title, - description: row.description, - type: row.type, - start: row.start, - end: row.end, - }; + const userNotifications = groupEventsByUser(eventData); - // Get user's email - const userEmail = await getUserEmail(userId); - - if (!userEmail) { - 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}`); - - 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++; - } - } + const stats = await sendNotificationsToUsers(jmapConfig, userNotifications, { + getSubject: generateEventNotificationSubject, + logPrefix: '', + }); logger.info(`=== Hotovo: odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`); - return stats; + return { ...stats, eventTitle }; }; /** - * Main function to send event notifications - * @returns {Promise<{ sent: number, failed: number, skipped: number }>} + * Send notifications for tomorrow's events */ 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; + return { sent: 0, failed: 0, skipped: 0 }; } - const jmapConfig = { - server: process.env.JMAP_SERVER, - username: senderAccount.email, - password: senderAccount.password, - accountId: senderAccount.jmapAccountId, - }; + const jmapConfig = buildJmapConfig(senderAccount); - // Get tomorrow's events with assigned users - const tomorrowEvents = await getTomorrowEvents(); + const now = new Date(); + const startOfTomorrow = new Date(now); + startOfTomorrow.setDate(startOfTomorrow.getDate() + 1); + startOfTomorrow.setHours(0, 0, 0, 0); + const endOfTomorrow = new Date(startOfTomorrow); + endOfTomorrow.setHours(23, 59, 59, 999); + + const tomorrowEvents = await getEventsInRange(startOfTomorrow, endOfTomorrow); if (tomorrowEvents.length === 0) { logger.info('Žiadne udalosti na zajtra'); - return stats; + return { sent: 0, failed: 0, skipped: 0 }; } 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, - }, - }); - } - } - + const userNotifications = groupEventsByUser(tomorrowEvents); 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) { - 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} - 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++; - } - } + const stats = await sendNotificationsToUsers(jmapConfig, userNotifications, { + getSubject: generateEventNotificationSubject, + logPrefix: '', + }); logger.info(`=== Hotovo: odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`); @@ -508,94 +374,42 @@ export const sendEventNotifications = async () => { }; /** - * Send notifications for events starting in the next hour (1 hour before meeting) - * @returns {Promise<{ sent: number, failed: number, skipped: number }>} + * Send notifications for events starting in the next hour */ export const sendOneHourBeforeNotifications = async () => { logger.info('=== Spúšťam kontrolu udalostí začínajúcich v najbližšej hodine ==='); - 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; + return { sent: 0, failed: 0, skipped: 0 }; } - const jmapConfig = { - server: process.env.JMAP_SERVER, - username: senderAccount.email, - password: senderAccount.password, - accountId: senderAccount.jmapAccountId, - }; + const jmapConfig = buildJmapConfig(senderAccount); - // Get events starting in the next hour - const upcomingEvents = await getUpcomingEvents(); + const now = new Date(); + const endOfRange = new Date(now); + endOfRange.setHours(endOfRange.getHours() + 1); + + const upcomingEvents = await getEventsInRange(now, endOfRange); if (upcomingEvents.length === 0) { logger.info('Žiadne udalosti v najbližšej hodine'); - return stats; + return { sent: 0, failed: 0, skipped: 0 }; } logger.info(`Nájdených ${upcomingEvents.length} priradení udalostí v najbližšej hodine`); - // Group events by user to avoid duplicate notifications for same event - const userNotifications = new Map(); - - for (const row of upcomingEvents) { - 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, - }, - }); - } - } - + const userNotifications = groupEventsByUser(upcomingEvents); 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) { - stats.skipped++; - continue; - } - - // Generate email content with "1 hour before" subject - const typeLabel = event.type === 'meeting' ? 'Stretnutie' : (event.type === 'important' ? 'Dôležité' : 'Udalosť'); - const subject = `Pripomienka: ${typeLabel} - ${event.title} (o 1 hodinu)`; - const htmlBody = generateEventNotificationHtml({ firstName, username, event }); - const textBody = generateEventNotificationText({ firstName, username, event }); - - // Send email - logger.info(`Odosielam 1h notifikáciu pre ${username} - 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++; - } - } + const stats = await sendNotificationsToUsers(jmapConfig, userNotifications, { + getSubject: (event) => { + const typeLabel = event.type === 'meeting' ? 'Stretnutie' : (event.type === 'important' ? 'Dôležité' : 'Udalosť'); + return `Pripomienka: ${typeLabel} - ${event.title} (o 1 hodinu)`; + }, + logPrefix: '1h ', + }); logger.info(`=== Hotovo (1h notifikácie): odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`);