From 722c9fd80bb41bdcbbd0860d0f6d17ad2dc028a0 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Fri, 30 Jan 2026 12:51:13 +0100 Subject: [PATCH] feat: Add invitation email and improve email templates - Add invitation email service with Slovak diacritics - Move certificate email HTML template to separate file - Add invitation email HTML/TXT templates - Remove template caching for development flexibility - Add send invitation endpoint (POST /registracie/:id/send-invitation) Co-Authored-By: Claude Opus 4.5 --- src/controllers/ai-kurzy.controller.js | 19 + src/routes/ai-kurzy.routes.js | 8 + .../ai-kurzy/certificate-email.service.js | 161 ++------ .../ai-kurzy/invitation-email.service.js | 253 +++++++++++++ src/templates/emails/certificate-email.html | 129 +++++++ src/templates/emails/certificate-email.txt | 20 + src/templates/emails/invitation-email.html | 351 ++++++++++++++++++ src/templates/emails/invitation-email.txt | 52 +++ 8 files changed, 869 insertions(+), 124 deletions(-) create mode 100644 src/services/ai-kurzy/invitation-email.service.js create mode 100644 src/templates/emails/certificate-email.html create mode 100644 src/templates/emails/certificate-email.txt create mode 100644 src/templates/emails/invitation-email.html create mode 100644 src/templates/emails/invitation-email.txt diff --git a/src/controllers/ai-kurzy.controller.js b/src/controllers/ai-kurzy.controller.js index 9b90ebd..d08173f 100644 --- a/src/controllers/ai-kurzy.controller.js +++ b/src/controllers/ai-kurzy.controller.js @@ -306,3 +306,22 @@ export const sendCertificateEmail = async (req, res, next) => { next(error); } }; + +// ==================== INVITATION EMAIL ==================== + +export const sendInvitationEmail = async (req, res, next) => { + try { + const { registraciaId } = req.params; + const userId = req.user.id; + const { sendInvitationEmail: sendInvitation } = await import('../services/ai-kurzy/invitation-email.service.js'); + + const result = await sendInvitation(parseInt(registraciaId), userId); + + res.json({ + data: result, + message: result.message, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/routes/ai-kurzy.routes.js b/src/routes/ai-kurzy.routes.js index e49b23b..bdb1e1b 100644 --- a/src/routes/ai-kurzy.routes.js +++ b/src/routes/ai-kurzy.routes.js @@ -180,4 +180,12 @@ router.post( aiKurzyController.sendCertificateEmail ); +// ==================== INVITATION EMAIL ==================== + +router.post( + '/registracie/:registraciaId/send-invitation', + validateParams(registraciaIdSchema), + aiKurzyController.sendInvitationEmail +); + export default router; diff --git a/src/services/ai-kurzy/certificate-email.service.js b/src/services/ai-kurzy/certificate-email.service.js index e4cf9c8..5f4080a 100644 --- a/src/services/ai-kurzy/certificate-email.service.js +++ b/src/services/ai-kurzy/certificate-email.service.js @@ -1,5 +1,6 @@ import fs from 'fs/promises'; import path from 'path'; +import { fileURLToPath } from 'url'; import axios from 'axios'; import { db } from '../../config/database.js'; import { prilohy, registracie, ucastnici, kurzy, emailAccounts, userEmailAccounts } from '../../db/schema.js'; @@ -9,6 +10,14 @@ import { logger } from '../../utils/logger.js'; import { jmapRequest, getMailboxes, getIdentities } from '../jmap/client.js'; import { getJmapConfig } from '../jmap/config.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Template paths +const TEMPLATES_DIR = path.join(__dirname, '../../templates/emails'); +const CERTIFICATE_EMAIL_HTML_TEMPLATE = path.join(TEMPLATES_DIR, 'certificate-email.html'); +const CERTIFICATE_EMAIL_TEXT_TEMPLATE = path.join(TEMPLATES_DIR, 'certificate-email.txt'); + /** * Format date to Slovak locale */ @@ -21,136 +30,40 @@ const formatDate = (date) => { }).format(new Date(date)); }; +/** + * Load email templates (fresh load each time for development flexibility) + */ +const loadTemplates = async () => { + const htmlTemplate = await fs.readFile(CERTIFICATE_EMAIL_HTML_TEMPLATE, 'utf-8'); + const textTemplate = await fs.readFile(CERTIFICATE_EMAIL_TEXT_TEMPLATE, 'utf-8'); + return { htmlTemplate, textTemplate }; +}; + +/** + * Replace template placeholders with actual values + */ +const replaceTemplatePlaceholders = (template, data) => { + return template + .replace(/\{\{participantName\}\}/g, data.participantName || '') + .replace(/\{\{courseName\}\}/g, data.courseName || '') + .replace(/\{\{courseDate\}\}/g, data.courseDate || '') + .replace(/\{\{courseType\}\}/g, data.courseType || 'Kurz'); +}; + /** * Generate HTML email template for certificate notification */ -export const generateCertificateEmailHtml = ({ participantName, courseName, courseDate, courseType }) => { - return ` - - - - - - Certifikát o absolvovaní kurzu - - - - - - -
- - - - - - - - - - - - - - - - - -
-
- 🎓 -
-

- Gratulujeme k úspešnému absolvovaniu! -

-
-

- Vážený/á ${participantName}, -

- -

- Srdečne Vám ďakujeme za účasť na našom kurze. Úspešne ste ho absolvovali a s potešením Vám zasielame Váš certifikát. -

- - - - - - -
- - - - - - - -
- - ${courseType || 'Kurz'} - -
-

- ${courseName} -

-

- 📅 - ${courseDate} -

-
-
- - -
-

- 📎 Príloha: Váš certifikát nájdete v prílohe tohto emailu vo formáte PDF. -

-
- -

- Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi! -

- -

- S pozdravom,
- Tím AI Kurzov -

-
-

- Táto správa bola automaticky vygenerovaná.
- V prípade otázok nás neváhajte kontaktovať. -

-
-
- - -`.trim(); +export const generateCertificateEmailHtml = async ({ participantName, courseName, courseDate, courseType }) => { + const { htmlTemplate } = await loadTemplates(); + return replaceTemplatePlaceholders(htmlTemplate, { participantName, courseName, courseDate, courseType }); }; /** * Generate plain text version of the certificate email */ -export const generateCertificateEmailText = ({ participantName, courseName, courseDate, courseType }) => { - return ` -Vážený/á ${participantName}, - -Srdečne Vám ďakujeme za účasť na našom kurze. Úspešne ste ho absolvovali a s potešením Vám zasielame Váš certifikát. - -KURZ: ${courseName} -TYP: ${courseType || 'Kurz'} -DÁTUM: ${courseDate} - -Váš certifikát nájdete v prílohe tohto emailu vo formáte PDF. - -Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi! - -S pozdravom, -Tím AI Kurzov - ---- -Táto správa bola automaticky vygenerovaná. -V prípade otázok nás neváhajte kontaktovať. -`.trim(); +export const generateCertificateEmailText = async ({ participantName, courseName, courseDate, courseType }) => { + const { textTemplate } = await loadTemplates(); + return replaceTemplatePlaceholders(textTemplate, { participantName, courseName, courseDate, courseType }); }; /** @@ -410,8 +323,8 @@ export const sendCertificateEmail = async (prilohaId, userId) => { courseType: registration.kurzTyp, }; - const htmlBody = generateCertificateEmailHtml(emailData); - const textBody = generateCertificateEmailText(emailData); + const htmlBody = await generateCertificateEmailHtml(emailData); + const textBody = await generateCertificateEmailText(emailData); const subject = `Certifikát o absolvovaní kurzu: ${registration.kurzNazov}`; // Get JMAP config diff --git a/src/services/ai-kurzy/invitation-email.service.js b/src/services/ai-kurzy/invitation-email.service.js new file mode 100644 index 0000000..708ed07 --- /dev/null +++ b/src/services/ai-kurzy/invitation-email.service.js @@ -0,0 +1,253 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import axios from 'axios'; +import { db } from '../../config/database.js'; +import { registracie, ucastnici, kurzy, emailAccounts, userEmailAccounts } from '../../db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import { NotFoundError, BadRequestError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import { jmapRequest, getMailboxes, getIdentities } from '../jmap/client.js'; +import { getJmapConfig } from '../jmap/config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Template paths +const TEMPLATES_DIR = path.join(__dirname, '../../templates/emails'); +const INVITATION_EMAIL_HTML_TEMPLATE = path.join(TEMPLATES_DIR, 'invitation-email.html'); +const INVITATION_EMAIL_TEXT_TEMPLATE = path.join(TEMPLATES_DIR, 'invitation-email.txt'); + +/** + * Format date to Slovak locale + */ +const formatDate = (date) => { + if (!date) return ''; + return new Intl.DateTimeFormat('sk-SK', { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(date)); +}; + +/** + * Load email templates (fresh load each time for development flexibility) + */ +const loadTemplates = async () => { + const htmlTemplate = await fs.readFile(INVITATION_EMAIL_HTML_TEMPLATE, 'utf-8'); + const textTemplate = await fs.readFile(INVITATION_EMAIL_TEXT_TEMPLATE, 'utf-8'); + return { htmlTemplate, textTemplate }; +}; + +/** + * Replace template placeholders with actual values + */ +const replaceTemplatePlaceholders = (template, data) => { + return template + .replace(/\{\{courseName\}\}/g, data.courseName || '') + .replace(/\{\{courseDate\}\}/g, data.courseDate || ''); +}; + +/** + * Generate HTML email template for invitation + */ +export const generateInvitationEmailHtml = async ({ courseName, courseDate }) => { + const { htmlTemplate } = await loadTemplates(); + return replaceTemplatePlaceholders(htmlTemplate, { courseName, courseDate }); +}; + +/** + * Generate plain text version of the invitation email + */ +export const generateInvitationEmailText = async ({ courseName, courseDate }) => { + const { textTemplate } = await loadTemplates(); + return replaceTemplatePlaceholders(textTemplate, { courseName, courseDate }); +}; + +/** + * Send email via JMAP + */ +const sendEmailViaJmap = async (jmapConfig, to, subject, htmlBody, textBody) => { + logger.info(`Odosielam pozvánku emailom na: ${to}`); + + // Get mailboxes + const mailboxes = await getMailboxes(jmapConfig); + const sentMailbox = mailboxes.find((m) => m.role === 'sent' || m.name === 'Sent'); + + if (!sentMailbox) { + throw new Error('Priečinok Odoslané nebol nájdený'); + } + + // Create email + const createResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + create: { + draft: { + mailboxIds: { + [sentMailbox.id]: true, + }, + from: [{ email: jmapConfig.username }], + to: [{ email: to }], + subject: subject, + bodyStructure: { + type: 'multipart/alternative', + subParts: [ + { + type: 'text/plain', + partId: 'text', + }, + { + type: 'text/html', + partId: 'html', + }, + ], + }, + bodyValues: { + html: { value: htmlBody }, + text: { value: textBody }, + }, + }, + }, + }, + 'set1', + ], + ]); + + const createResult = createResponse.methodResponses[0][1]; + const createdEmailId = createResult.created?.draft?.id; + + if (!createdEmailId) { + logger.error('Nepodarilo sa vytvoriť email', createResult.notCreated?.draft); + throw new Error('Nepodarilo sa vytvoriť email'); + } + + // Get user identity + const identities = await getIdentities(jmapConfig); + const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0]; + + if (!identity) { + throw new Error('Nenašla sa identita pre odosielanie emailov'); + } + + // 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) { + const error = submitResponse.methodResponses[0][1].notCreated?.submission; + logger.error('Nepodarilo sa odoslať email', error); + throw new Error('Nepodarilo sa odoslať email'); + } + + logger.success(`Email s pozvánkou úspešne odoslaný`); + return { success: true, submissionId }; +}; + +/** + * Send invitation email to participant + * @param {number} registraciaId - ID of the registration + * @param {number} userId - ID of the user sending the email (for getting email account) + */ +export const sendInvitationEmail = async (registraciaId, userId) => { + // Get registration with participant and course info + const [registration] = await db + .select({ + id: registracie.id, + ucastnikMeno: ucastnici.meno, + ucastnikPriezvisko: ucastnici.priezvisko, + ucastnikTitul: ucastnici.titul, + ucastnikEmail: ucastnici.email, + kurzNazov: kurzy.nazov, + kurzTyp: kurzy.typKurzu, + kurzDatumOd: kurzy.datumOd, + kurzDatumDo: kurzy.datumDo, + }) + .from(registracie) + .leftJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id)) + .leftJoin(kurzy, eq(registracie.kurzId, kurzy.id)) + .where(eq(registracie.id, registraciaId)) + .limit(1); + + if (!registration) { + throw new NotFoundError('Registrácia nenájdená'); + } + + if (!registration.ucastnikEmail) { + throw new BadRequestError('Účastník nemá zadaný email'); + } + + // Get email account for sending (through userEmailAccounts join table) + const [emailAccountResult] = await db + .select({ + id: emailAccounts.id, + email: emailAccounts.email, + emailPassword: emailAccounts.emailPassword, + jmapAccountId: emailAccounts.jmapAccountId, + isActive: emailAccounts.isActive, + }) + .from(userEmailAccounts) + .innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id)) + .where(and(eq(userEmailAccounts.userId, userId), eq(emailAccounts.isActive, true))) + .limit(1); + + if (!emailAccountResult) { + throw new BadRequestError('Nemáte nastavený aktívny emailový účet pre odosielanie'); + } + + const emailAccount = emailAccountResult; + + // Build course date string + const courseDate = registration.kurzDatumOd + ? registration.kurzDatumDo && registration.kurzDatumDo !== registration.kurzDatumOd + ? `${formatDate(registration.kurzDatumOd)} - ${formatDate(registration.kurzDatumDo)}` + : formatDate(registration.kurzDatumOd) + : ''; + + // Generate email content + const emailData = { + courseName: registration.kurzNazov || 'Kurz', + courseDate, + }; + + const htmlBody = await generateInvitationEmailHtml(emailData); + const textBody = await generateInvitationEmailText(emailData); + const subject = `Pozvánka na školenie: ${registration.kurzNazov}`; + + // Get JMAP config + const jmapConfig = getJmapConfig(emailAccount, true); + + // Send email + await sendEmailViaJmap( + jmapConfig, + registration.ucastnikEmail, + subject, + htmlBody, + textBody + ); + + logger.success(`Pozvánka odoslaná na ${registration.ucastnikEmail}`); + + return { + success: true, + message: `Pozvánka bola odoslaná na ${registration.ucastnikEmail}`, + recipientEmail: registration.ucastnikEmail, + }; +}; diff --git a/src/templates/emails/certificate-email.html b/src/templates/emails/certificate-email.html new file mode 100644 index 0000000..16e5b94 --- /dev/null +++ b/src/templates/emails/certificate-email.html @@ -0,0 +1,129 @@ + + + + + + Certifikát o absolvovaní kurzu + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ Slovensko.AI +
+
+ 🎓 +
+

+ Gratulujeme k úspešnému absolvovaniu! +

+
+
+

+ Vážený/á {{participantName}}, +

+ +

+ Srdečne Vám ďakujeme za účasť na našom kurze. Úspešne ste ho absolvovali a s potešením Vám zasielame Váš certifikát. +

+ + + + + + + + + +
+ + {{courseType}} + +
+

+ {{courseName}} +

+

+ 📅 + {{courseDate}} +

+
+ + + + + + +
+ + + + + +
+
+ 📎 +
+
+

+ Príloha: Váš certifikát nájdete v prílohe tohto e-mailu vo formáte PDF. +

+
+
+ +

+ Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi! +

+ +

+ S pozdravom,
+ Tím Slovensko.AI +

+
+ + + + +
+

+ Táto správa bola automaticky vygenerovaná.
+ V prípade otázok nás neváhajte kontaktovať. +

+

+ info@slovensko.ai + | + www.slovensko.ai +

+
+
+
+ + diff --git a/src/templates/emails/certificate-email.txt b/src/templates/emails/certificate-email.txt new file mode 100644 index 0000000..158cb59 --- /dev/null +++ b/src/templates/emails/certificate-email.txt @@ -0,0 +1,20 @@ +Vážený/á {{participantName}}, + +Srdečne Vám ďakujeme za účasť na našom kurze. Úspešne ste ho absolvovali a s potešením Vám zasielame Váš certifikát. + +KURZ: {{courseName}} +TYP: {{courseType}} +DÁTUM: {{courseDate}} + +Váš certifikát nájdete v prílohe tohto e-mailu vo formáte PDF. + +Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi! + +S pozdravom, +Tím Slovensko.AI + +--- +Táto správa bola automaticky vygenerovaná. +V prípade otázok nás neváhajte kontaktovať. + +info@slovensko.ai | www.slovensko.ai diff --git a/src/templates/emails/invitation-email.html b/src/templates/emails/invitation-email.html new file mode 100644 index 0000000..db59373 --- /dev/null +++ b/src/templates/emails/invitation-email.html @@ -0,0 +1,351 @@ + + + + + + Pozvánka na kurz + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ Slovensko.AI +
+
+

+ Pozvánka na školenie +

+

+ Všetky organizačné informácie +

+
+
+

+ Dobrý deň, +

+

+ pripravili sme pre Vás súhrn všetkých potrebných organizačných informácií k Vášmu kurzu. Potvrďte nám prosím prečítanie tohto e-mailu. +

+
+ + + + + + + + +
+

+ Kurz +

+
+ +

+ {{courseName}} +

+ + + + + + + +
+
+

+ 📅 Termín +

+

+ {{courseDate}} +

+
+
+
+

+ 🕑 Čas +

+

+ 09:00 - 15:00 +

+

+ + Diskusia +

+
+
+ + +
+

+ 🎓 Forma školenia +

+

+ Školenie sa bude konať prezenčne. Program bude pozostávať z výukových blokov, s prestávkami 10-15 minút a jednou 45-60 minútovou obedovou prestávkou. +

+
+
+
+ + + + +
+ + + + + +
+
+ 💡 +
+
+

+ Tip pre lepší zážitok +

+

+ Odporúčame priniesť si vlastný notebook. Budete si môcť vyskúšať AI priamo v akcii počas praktických úloh. V cene školenia je občerstvenie (káva, čaj). +

+
+
+
+ + + + +
+ + + + + +
+
+ 📍 +
+
+

+ Miesto konania +

+

+ INBOX SK s.r.o. +

+

+ Seberíniho 1, Bratislava
+ 7. poschodie vpravo +

+
+
+
+

+ 🚌 Doprava a parkovanie +

+ + + + + + +
+ + + + + + + + + + + + + +
+ + + + + +
+
+ B +
+
+

+ Autobusom: Nevädzová (66, 75, 96), Tomášikova (50) +

+
+
+ + + + + +
+
+ T +
+
+

+ Trolejbusom: Gagarinova (71, 72) +

+

+ Z Hlavnej stanice trolejbus 71, z Autobusovej stanice trolejbus 72 +

+
+
+ + + + + +
+
+ E +
+
+

+ Električkou: Tomášikova (9) +

+
+
+ + + + + +
+
+ A +
+
+

+ Autom: GPS N 48° 09" 13.295', E 17° 09" 53.773' +

+
+
+
+ + + + + + +
+

+ 🚗 Parkovanie +

+
    +
  • Parkovisko pri budove: 3,70 EUR/hod (15 min zadarmo)
  • +
  • Hotel Bratislava: 10 EUR/celý deň
  • +
  • PAAS zóna Ružinov-Pošeň: 1,50 EUR/hod (od 12:00) - zaparkujete na uliciach Obilná, Jašíkova, Andreja Mráza, Seberíniho, Babušková
  • +
  • Parkovisko pred budovou je určené pre nájomníkov, nie pre hostí
  • +
+
+
+ + 🗺 Zobraziť na mape + +
+
+

+ Tešíme sa na Vás! +

+

+ Prajeme Vám veľa úspechov pri školení. +

+
+ + + + +
+

+ Tento e-mail je odosielaný účastníkom školenia pred jeho začatím. +

+ + + + + +
+

+ INBOX SK s.r.o.
+ Seberíniho 1, 821 03 Bratislava +

+
+

+ +421 950 608 326
+ info@slovensko.ai +

+
+

+ IČO: 44813295 | IČ DPH: SK2022842657 | 2621117083/1100 (TatraBanka) +

+

+ www.slovensko.ai +

+
+
+
+ + diff --git a/src/templates/emails/invitation-email.txt b/src/templates/emails/invitation-email.txt new file mode 100644 index 0000000..44154c4 --- /dev/null +++ b/src/templates/emails/invitation-email.txt @@ -0,0 +1,52 @@ +Dobrý deň, + +pripravili sme pre Vás súhrn všetkých potrebných organizačných informácií k Vášmu kurzu. +Potvrďte nám prosím prečítanie tohto e-mailu. + +═══════════════════════════════════════════════════════════ + +KURZ: {{courseName}} +TERMÍN: {{courseDate}} +ČAS: 09:00 - 15:00 + Diskusia + +Školenie sa bude konať prezenčne. Program bude pozostávať z výukových blokov, s prestávkami 10-15 minút a jednou 45-60 minútovou obedovou prestávkou. + +TIP: Pre ešte lepší zážitok zo školenia odporúčame priniesť si vlastný notebook. V cene školenia je občerstvenie (káva, čaj). + +═══════════════════════════════════════════════════════════ + +MIESTO KONANIA: +INBOX SK s.r.o. +Seberíniho 1, Bratislava +7. poschodie vpravo + +═══════════════════════════════════════════════════════════ + +DOPRAVA: +• Autobusom: Nevädzová (66, 75, 96), Tomášikova (50) +• Trolejbusom: Gagarinova (71, 72) + Z Hlavnej stanice trolejbus 71, z Autobusovej stanice trolejbus 72 +• Električkou: Tomášikova (9) +• Autom: GPS N 48° 09" 13.295', E 17° 09" 53.773' + +PARKOVANIE: +• Parkovisko pri budove: 3,70 EUR/hod (15 min zadarmo) +• Hotel Bratislava: 10 EUR/celý deň +• PAAS zóna Ružinov-Pošeň: 1,50 EUR/hod (od 12:00) + Zaparkujete na uliciach Obilná, Jašíkova, Andreja Mráza, Seberíniho, Babušková +• Parkovisko pred budovou je určené pre nájomníkov, nie pre hostí + +MAPA: https://www.google.sk/maps/place/Seber%C3%ADniho+482%2F1,+821+04+Bratislava/@48.1537526,17.1627419,17z + +═══════════════════════════════════════════════════════════ + +Tešíme sa na Vás! +Prajeme Vám veľa úspechov pri školení. + +--- +Tento e-mail je odosielaný účastníkom školenia pred jeho začatím. + +INBOX SK s.r.o., Seberíniho 1, 821 03 Bratislava +Kontakt: +421 950 608 326, info@slovensko.ai +www.slovensko.ai +IČO: 44813295 | IČ DPH: SK2022842657 | 2621117083/1100 (TatraBanka)