refactor: Deduplicate event-notifier.js (603 -> 418 lines)

Extract shared helpers:
- getEventsInRange(start, end) replaces getTomorrowEvents + getUpcomingEvents
- groupEventsByUser() deduplicates event grouping logic from 3 functions
- sendNotificationsToUsers() deduplicates notification loop from 3 functions
- buildJmapConfig() removes repeated JMAP config construction

Remove unused standalone range helper functions (getTomorrowRange,
getOneHourRange) — date ranges computed inline where needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-28 07:25:35 +01:00
parent d4883480b2
commit caab86079e

View File

@@ -10,10 +10,8 @@ import {
generateEventNotificationSubject, generateEventNotificationSubject,
} from './email-template.js'; } from './email-template.js';
/** // --- Private helpers ---
* Get sender email account credentials from database
* @returns {Promise<Object|null>} Sender account with decrypted password
*/
const getSenderAccount = async () => { const getSenderAccount = async () => {
const senderEmail = process.env.NOTIFICATION_SENDER_EMAIL; 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<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 events starting in the next hour with assigned users
* @returns {Promise<Array>} 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<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;
}
};
/**
* Get sender account by user ID (admin's primary email)
* @param {string} userId - Admin user ID
* @returns {Promise<Object|null>} Sender account with decrypted password
*/
const getSenderAccountByUserId = async (userId) => { const getSenderAccountByUserId = async (userId) => {
const [result] = await db const [result] = await db
.select({ .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 * 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) => { export const sendSingleEventNotification = async (eventId, adminUserId) => {
logger.info(`=== Odosielam notifikácie pre event ${eventId} od admina ${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); const senderAccount = await getSenderAccountByUserId(adminUserId);
if (!senderAccount) { if (!senderAccount) {
throw new Error('Nemáte nastavený primárny email účet. Nastavte ho v profile.'); throw new Error('Nemáte nastavený primárny email účet. Nastavte ho v profile.');
} }
const jmapConfig = { const jmapConfig = buildJmapConfig(senderAccount);
server: process.env.JMAP_SERVER,
username: senderAccount.email,
password: senderAccount.password,
accountId: senderAccount.jmapAccountId,
};
logger.info(`Odosielam z účtu: ${senderAccount.email}`); logger.info(`Odosielam z účtu: ${senderAccount.email}`);
// Get the event with assigned users
const eventData = await db const eventData = await db
.select({ .select({
eventId: events.id, 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'); throw new Error('Event nebol nájdený alebo nemá priradených používateľov');
} }
stats.eventTitle = eventData[0].title; const eventTitle = eventData[0].title;
logger.info(`Event: ${stats.eventTitle}, priradených používateľov: ${eventData.length}`); logger.info(`Event: ${eventTitle}, priradených používateľov: ${eventData.length}`);
// Send notifications to each assigned user const userNotifications = groupEventsByUser(eventData);
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,
};
// Get user's email const stats = await sendNotificationsToUsers(jmapConfig, userNotifications, {
const userEmail = await getUserEmail(userId); getSubject: generateEventNotificationSubject,
logPrefix: '',
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++;
}
}
logger.info(`=== Hotovo: odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`); 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 * Send notifications for tomorrow's events
* @returns {Promise<{ sent: number, failed: number, skipped: number }>}
*/ */
export const sendEventNotifications = async () => { export const sendEventNotifications = async () => {
logger.info('=== Spúšťam kontrolu zajtrajších udalostí ==='); logger.info('=== Spúšťam kontrolu zajtrajších udalostí ===');
const stats = { sent: 0, failed: 0, skipped: 0 };
// Get sender account
const senderAccount = await getSenderAccount(); const senderAccount = await getSenderAccount();
if (!senderAccount) { if (!senderAccount) {
logger.error('Nemôžem pokračovať bez platného odosielacieho účtu'); logger.error('Nemôžem pokračovať bez platného odosielacieho účtu');
return stats; return { sent: 0, failed: 0, skipped: 0 };
} }
const jmapConfig = { const jmapConfig = buildJmapConfig(senderAccount);
server: process.env.JMAP_SERVER,
username: senderAccount.email,
password: senderAccount.password,
accountId: senderAccount.jmapAccountId,
};
// Get tomorrow's events with assigned users const now = new Date();
const tomorrowEvents = await getTomorrowEvents(); 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) { if (tomorrowEvents.length === 0) {
logger.info('Žiadne udalosti na zajtra'); 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`); logger.info(`Nájdených ${tomorrowEvents.length} priradení udalostí na zajtra`);
// Group events by user to avoid duplicate notifications for same event const userNotifications = groupEventsByUser(tomorrowEvents);
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}`); logger.info(`Unikátnych notifikácií na odoslanie: ${userNotifications.size}`);
// Send notifications const stats = await sendNotificationsToUsers(jmapConfig, userNotifications, {
for (const [key, data] of userNotifications) { getSubject: generateEventNotificationSubject,
const { userId, username, firstName, event } = data; logPrefix: '',
});
// 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++;
}
}
logger.info(`=== Hotovo: odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`); 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) * Send notifications for events starting in the next hour
* @returns {Promise<{ sent: number, failed: number, skipped: number }>}
*/ */
export const sendOneHourBeforeNotifications = async () => { export const sendOneHourBeforeNotifications = async () => {
logger.info('=== Spúšťam kontrolu udalostí začínajúcich v najbližšej hodine ==='); 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(); const senderAccount = await getSenderAccount();
if (!senderAccount) { if (!senderAccount) {
logger.error('Nemôžem pokračovať bez platného odosielacieho účtu'); logger.error('Nemôžem pokračovať bez platného odosielacieho účtu');
return stats; return { sent: 0, failed: 0, skipped: 0 };
} }
const jmapConfig = { const jmapConfig = buildJmapConfig(senderAccount);
server: process.env.JMAP_SERVER,
username: senderAccount.email,
password: senderAccount.password,
accountId: senderAccount.jmapAccountId,
};
// Get events starting in the next hour const now = new Date();
const upcomingEvents = await getUpcomingEvents(); const endOfRange = new Date(now);
endOfRange.setHours(endOfRange.getHours() + 1);
const upcomingEvents = await getEventsInRange(now, endOfRange);
if (upcomingEvents.length === 0) { if (upcomingEvents.length === 0) {
logger.info('Žiadne udalosti v najbližšej hodine'); 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`); 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 = groupEventsByUser(upcomingEvents);
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,
},
});
}
}
logger.info(`Unikátnych notifikácií na odoslanie: ${userNotifications.size}`); logger.info(`Unikátnych notifikácií na odoslanie: ${userNotifications.size}`);
// Send notifications const stats = await sendNotificationsToUsers(jmapConfig, userNotifications, {
for (const [key, data] of userNotifications) { getSubject: (event) => {
const { userId, username, firstName, event } = data; const typeLabel = event.type === 'meeting' ? 'Stretnutie' : (event.type === 'important' ? 'Dôležité' : 'Udalosť');
return `Pripomienka: ${typeLabel} - ${event.title} (o 1 hodinu)`;
// Get user's email },
const userEmail = await getUserEmail(userId); logPrefix: '1h ',
});
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++;
}
}
logger.info(`=== Hotovo (1h notifikácie): odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`); logger.info(`=== Hotovo (1h notifikácie): odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`);