Implement Email Notification System and Update Notification API

- Introduced a new API endpoint for sending email notifications using Nodemailer, allowing for customizable email content and recipient management.
- Enhanced the notification configuration to support various recipient types (email, user, role, variable) and added error handling for recipient resolution.
- Updated the process builder to include HTML message formatting and improved logging for notification processing.
- Modified the Nuxt configuration to include Nodemailer as a dependency and adjusted security settings for the notifications API.
- Refactored process definition JSON to accommodate changes in notification handling and updated UI components accordingly.
This commit is contained in:
Md Afiq Iskandar 2025-07-25 11:12:58 +08:00
parent 82ff3ecfc3
commit 4c67a79be0
7 changed files with 576 additions and 38 deletions

View File

@ -89,15 +89,15 @@
"targetHandle": "html-1752550500000-left"
},
{
"id": "gateway-1752550505000-notification-1752621850786",
"data": { "condition": "todoStatus === false" },
"id": "gateway-1752550505000-form-1752546702226-1753409198979",
"data": {},
"type": "custom",
"label": "Not Completed",
"source": "gateway-1752550505000",
"target": "notification-1752621850786",
"target": "form-1752546702226",
"animated": true,
"sourceHandle": "gateway-1752550505000-bottom",
"targetHandle": "notification-1752621850786-left"
"targetHandle": "form-1752546702226-top"
}
],
"nodes": [
@ -106,14 +106,14 @@
"data": { "label": "Start", "description": "Process start point" },
"type": "start",
"label": "Start",
"position": { "x": 300, "y": 135 }
"position": { "x": 300, "y": 300 }
},
{
"id": "form-1752546702226",
"data": {
"label": "Pilihan Kategori Asnaf",
"shape": "rectangle",
"formId": 7,
"formId": 1,
"formName": "Pilihan Kategori Asnaf",
"formUuid": "d3612e05-b31a-46dc-b5e5-67e6c5bd3e78",
"textColor": "#6b21a8",
@ -136,7 +136,7 @@
},
"type": "form",
"label": "Pilihan Kategori Asnaf",
"position": { "x": 510, "y": 105 }
"position": { "x": 555, "y": 270 }
},
{
"id": "end-1752546716111",
@ -170,16 +170,20 @@
},
"type": "api",
"label": "API Call",
"position": { "x": 795, "y": 105 }
"position": { "x": 795, "y": -60 }
},
{
"id": "script-1752550430989",
"data": {
"label": "Script Task",
"shape": "rectangle",
"textColor": "#374151",
"scriptCode": "// Map API response to process variables\nconst api = processVariables.apiResponse || {};\nprocessVariables.todoTitle = api.kategori_asnaf || '';\nprocessVariables.namaAsnaf = api.nama_asnaf || '';\nprocessVariables.todoStatus = api.id > 100; // true if id > 100, otherwise false\n\n// New logic: Calculate a score\nconst katLen = (api.kategori_asnaf || '').length;\nconst namaLen = (api.nama_asnaf || '').length;\nprocessVariables.asnafScore = katLen * 10 + namaLen * 5;\n\n// New logic: Add a timestamp\nprocessVariables.resultTimestamp = new Date().toISOString();\n\n// New logic: Create a summary string\nprocessVariables.resultSummary = `Asnaf: ${processVariables.todoTitle}, Nama: ${processVariables.namaAsnaf}, Score: ${processVariables.asnafScore}, Time: ${processVariables.resultTimestamp}`;\n",
"borderColor": "#6b7280",
"description": "Execute JavaScript code",
"inputVariables": ["apiResponse"],
"scriptLanguage": "javascript",
"backgroundColor": "#f9fafb",
"outputVariables": [
{
"name": "todoTitle",
@ -215,7 +219,7 @@
},
"type": "script",
"label": "Script Task",
"position": { "x": 1050, "y": 120 }
"position": { "x": 1110, "y": -60 }
},
{
"id": "gateway-1752550505000",
@ -228,14 +232,6 @@
"id": "condition-group-1",
"output": "Completed",
"conditions": [
{
"id": "condition-1",
"value": true,
"operator": "eq",
"variable": "todoStatus",
"valueType": "boolean",
"logicalOperator": "and"
},
{
"id": "condition-1753408402567",
"value": "afiq",
@ -254,10 +250,12 @@
"conditions": [
{
"id": "condition-2",
"value": false,
"operator": "eq",
"variable": "todoStatus",
"valueType": "boolean",
"value": "afiq",
"maxValue": "",
"minValue": "",
"operator": "not_contains",
"variable": "namaAsnaf",
"valueType": "string",
"logicalOperator": "and"
}
]
@ -291,7 +289,7 @@
},
"type": "html",
"label": "Show Result",
"position": { "x": 1590, "y": 150 }
"position": { "x": 1635, "y": 150 }
},
{
"id": "notification-1752621850786",
@ -302,26 +300,26 @@
"priority": "medium",
"expiration": { "unit": "hours", "value": 24, "enabled": false },
"description": "Send notification to users",
"htmlMessage": "",
"messageFormat": "text",
"htmlMessage": "<div style=\"font-family: Arial, sans-serif; padding: 15px;\">\n <h2 style=\"color: #3b82f6;\">Notification Title</h2>\n <p>Hello {namaAsnaf},</p>\n <p>This is a basic notification message.</p>\n <p>Thank you,<br>Process Maker</p>\n</div>",
"messageFormat": "html",
"recipientRole": "",
"recipientType": "email",
"recipientUser": "",
"recipientEmail": "mdafiqiskandar@gmail.com",
"recipientGroup": "",
"deliveryOptions": { "sms": false, "email": false, "inApp": true },
"deliveryOptions": { "sms": false, "email": true, "inApp": true },
"richTextMessage": "",
"notificationType": "info",
"recipientVariable": ""
},
"type": "notification",
"label": "Notification",
"position": { "x": 1590, "y": 360 }
"position": { "x": 1815, "y": 405 }
}
],
"viewport": {
"x": -271.3433493533669,
"y": 163.8456958483416,
"zoom": 0.6965142034495484
"x": -145.563928050559,
"y": 328.559309674283,
"zoom": 0.7049100631988332
}
}

View File

@ -559,5 +559,11 @@ export default defineNuxtConfig({
requestSizeLimiter: false,
},
},
"/api/notifications/**": {
security: {
xssValidator: false,
requestSizeLimiter: false,
},
},
},
});

View File

@ -66,6 +66,7 @@
"luxon": "^3.1.0",
"marked": "^16.1.1",
"maska": "^1.5.0",
"nodemailer": "^7.0.5",
"pinia": "^2.1.6",
"prettier": "^3.3.3",
"prettier-plugin-vue": "^1.1.6",

View File

@ -676,14 +676,14 @@ const copyWorkflowLink = async (processId) => {
</button>
<!-- Analytics Button -->
<button
<!-- <button
@click="viewProcessAnalytics(process.id)"
class="p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg transition-colors"
title="View Analytics"
:disabled="loading"
>
<Icon name="material-symbols:analytics" class="text-lg" />
</button>
</button> -->
<!-- Show restore button for deleted processes -->
<button

View File

@ -693,29 +693,105 @@ const executeCurrentStep = async () => {
return;
} else if (currentNode.value?.type === 'notification') {
console.log(`[Workflow] Sending notification: ${currentNode.value.data?.label || currentNode.value.label}`);
// Enhanced notification node execution
// Extract notification configuration
const {
notificationType = 'info',
recipients = [],
message = '',
recipientType = 'email',
recipientUser = '',
recipientRole = '',
recipientVariable = '',
recipientEmail = '',
subject = '',
message = '',
messageFormat = 'text',
htmlMessage = '',
richTextMessage = '',
deliveryOptions = { inApp: true, email: false, sms: false },
priority = 'medium',
continueOnError = true
} = currentNode.value.data || {};
try {
// Simulate notification sending
console.log('[Workflow] Notification sent:', { type: notificationType, recipients, message, subject });
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call delay
// Only send email if email delivery is enabled
if (deliveryOptions.email && (subject || message)) {
console.log('[Workflow] Sending email notification...');
// Prepare notification payload
const notificationPayload = {
notificationType,
recipientType,
recipientData: {
recipientUser,
recipientRole,
recipientVariable,
recipientEmail
},
subject: substituteVariables(subject, processVariables.value),
message: substituteVariables(message, processVariables.value),
htmlMessage: htmlMessage ? substituteVariables(htmlMessage, processVariables.value) : '',
messageFormat,
processVariables: processVariables.value,
processName: process.value?.processName || 'Process Workflow',
deliveryOptions,
priority
};
// Send notification via API
const response = await $fetch('/api/notifications/send', {
method: 'POST',
body: notificationPayload
});
if (response.success) {
console.log('[Workflow] Email notification sent successfully:', response);
// You could update process variables with notification results if needed
processVariables.value.lastNotificationResult = {
success: true,
sent: response.sent,
recipients: response.recipients,
timestamp: new Date().toISOString()
};
} else {
throw new Error(response.message || 'Failed to send notification');
}
} else {
console.log('[Workflow] Email delivery disabled or no content - skipping email notification');
// Handle in-app notifications if enabled
if (deliveryOptions.inApp) {
console.log('[Workflow] In-app notification would be displayed here');
// In a real implementation, you would add the notification to a queue
// or trigger an in-app notification system
}
// Set notification result in process variables
processVariables.value.lastNotificationResult = {
success: true,
sent: 0,
recipients: 0,
timestamp: new Date().toISOString(),
note: 'Email delivery disabled'
};
}
// Only auto-progress if there's a single outgoing edge
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] Notification sent, multiple paths available - waiting for user choice');
console.log('[Workflow] Notification processed, multiple paths available - waiting for user choice');
}
} catch (err) {
console.error('[Workflow] Notification failed:', err);
// Set error in process variables
processVariables.value.lastNotificationError = {
error: err.message || String(err),
timestamp: new Date().toISOString()
};
if (continueOnError) {
console.log('[Workflow] Continuing despite notification error');
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {

View File

@ -0,0 +1,452 @@
import nodemailer from 'nodemailer';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Email templates for different notification types
const emailTemplates = {
info: {
color: '#3b82f6',
backgroundColor: '#eff6ff',
icon: ''
},
success: {
color: '#10b981',
backgroundColor: '#ecfdf5',
icon: '✅'
},
warning: {
color: '#f59e0b',
backgroundColor: '#fffbeb',
icon: '⚠️'
},
error: {
color: '#ef4444',
backgroundColor: '#fef2f2',
icon: '❌'
},
reminder: {
color: '#8b5cf6',
backgroundColor: '#f3e8ff',
icon: '⏰'
},
status: {
color: '#6366f1',
backgroundColor: '#eef2ff',
icon: '📊'
}
};
// Create Nodemailer transporter with configurable email provider settings
function createTransporter() {
// Default configuration for common providers
const providerConfigs = {
gmail: {
host: 'smtp.gmail.com',
port: 587,
secure: false
},
outlook: {
host: 'smtp-mail.outlook.com',
port: 587,
secure: false
},
yahoo: {
host: 'smtp.mail.yahoo.com',
port: 587,
secure: false
},
proton: {
host: 'mail.protonmail.ch',
port: 587,
secure: false,
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
}
}
};
// Get provider from environment or default to custom
const provider = process.env.EMAIL_PROVIDER?.toLowerCase() || 'custom';
// Use provider-specific config or custom config
let config;
if (providerConfigs[provider]) {
config = {
...providerConfigs[provider],
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD
}
};
} else {
// Custom SMTP configuration
config = {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true', // true for SSL (port 465), false for STARTTLS (port 587)
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD
}
};
// Add custom TLS settings if provided
if (process.env.SMTP_TLS_CIPHERS || process.env.SMTP_TLS_REJECT_UNAUTHORIZED) {
config.tls = {};
if (process.env.SMTP_TLS_CIPHERS) {
config.tls.ciphers = process.env.SMTP_TLS_CIPHERS;
}
if (process.env.SMTP_TLS_REJECT_UNAUTHORIZED) {
config.tls.rejectUnauthorized = process.env.SMTP_TLS_REJECT_UNAUTHORIZED === 'true';
}
}
}
return nodemailer.createTransport(config);
}
// Generate HTML email template
function generateEmailTemplate(notificationType, subject, message, processName) {
const template = emailTemplates[notificationType] || emailTemplates.info;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; }
.header { background-color: ${template.backgroundColor}; padding: 20px; border-bottom: 3px solid ${template.color}; }
.header h1 { color: ${template.color}; margin: 0; font-size: 24px; }
.icon { font-size: 32px; margin-bottom: 10px; }
.content { padding: 30px; }
.message { font-size: 16px; line-height: 1.6; color: #333; margin-bottom: 20px; }
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #6c757d; border-top: 1px solid #e9ecef; }
.process-info { background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid ${template.color}; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="icon">${template.icon}</div>
<h1>${subject}</h1>
</div>
<div class="content">
<div class="message">
${message.replace(/\n/g, '<br>')}
</div>
${processName ? `
<div class="process-info">
<strong>Process:</strong> ${processName}
</div>
` : ''}
</div>
<div class="footer">
<p>This is an automated notification from the Corrad Process Management System.</p>
<p>Please do not reply to this email.</p>
</div>
</div>
</body>
</html>
`;
}
// Resolve recipients based on recipient type
async function resolveRecipients(recipientType, recipientData, processVariables) {
const recipients = [];
try {
switch (recipientType) {
case 'email':
// Direct email address
if (recipientData.recipientEmail && isValidEmail(recipientData.recipientEmail)) {
recipients.push({
email: recipientData.recipientEmail,
name: recipientData.recipientEmail
});
}
break;
case 'user':
// Look up user from database
if (recipientData.recipientUser) {
const user = await prisma.user.findUnique({
where: { id: recipientData.recipientUser },
select: { email: true, name: true, firstname: true, lastname: true }
});
if (user && user.email && isValidEmail(user.email)) {
const displayName = user.name || `${user.firstname || ''} ${user.lastname || ''}`.trim() || user.email;
recipients.push({
email: user.email,
name: displayName
});
}
}
break;
case 'role':
// Look up users by role
if (recipientData.recipientRole) {
const usersWithRole = await prisma.user.findMany({
where: {
userrole: {
some: {
roleid: recipientData.recipientRole
}
}
},
select: { email: true, name: true, firstname: true, lastname: true }
});
usersWithRole.forEach(user => {
if (user.email && isValidEmail(user.email)) {
const displayName = user.name || `${user.firstname || ''} ${user.lastname || ''}`.trim() || user.email;
recipients.push({
email: user.email,
name: displayName
});
}
});
}
break;
case 'variable':
// Get recipient from process variable
if (recipientData.recipientVariable && processVariables[recipientData.recipientVariable]) {
const variableValue = processVariables[recipientData.recipientVariable];
if (typeof variableValue === 'string' && isValidEmail(variableValue)) {
recipients.push({
email: variableValue,
name: variableValue
});
} else if (typeof variableValue === 'object' && variableValue.email && isValidEmail(variableValue.email)) {
recipients.push({
email: variableValue.email,
name: variableValue.name || variableValue.email
});
}
}
break;
case 'group':
// For future implementation - group-based recipients
console.warn('Group-based recipients not yet implemented');
break;
default:
console.warn(`Unknown recipient type: ${recipientType}`);
}
} catch (error) {
console.error('Error resolving recipients:', error);
}
return recipients;
}
// Validate email address
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Substitute variables in text
function substituteVariables(text, variables) {
if (typeof text !== 'string') return text;
// Replace {{variable}} format
text = text.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, varName) => {
const value = variables[varName];
if (value === undefined || value === null) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
});
// Replace {variable} format
text = text.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => {
const value = variables[varName];
if (value === undefined || value === null) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
});
return text;
}
export default defineEventHandler(async (event) => {
try {
// Only allow POST requests
if (event.node.req.method !== 'POST') {
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
});
}
// Parse request body
const body = await readBody(event);
const {
notificationType = 'info',
recipientType = 'email',
recipientData = {},
subject = '',
message = '',
processVariables = {},
processName = '',
deliveryOptions = { email: true },
priority = 'medium',
htmlMessage = '',
messageFormat = 'text'
} = body;
// Validate required fields
if (!subject && !message) {
throw createError({
statusCode: 400,
statusMessage: 'Subject or message is required'
});
}
// Check if email delivery is enabled
if (!deliveryOptions.email) {
return {
success: true,
message: 'Email delivery disabled for this notification',
sent: 0
};
}
// Check environment variables
if (!process.env.EMAIL_USER || !process.env.EMAIL_PASSWORD) {
throw createError({
statusCode: 500,
statusMessage: 'Email configuration missing. Please set EMAIL_USER and EMAIL_PASSWORD environment variables.'
});
}
// For custom SMTP, also check required SMTP settings
const provider = process.env.EMAIL_PROVIDER?.toLowerCase() || 'custom';
if (provider === 'custom' && !process.env.SMTP_HOST) {
throw createError({
statusCode: 500,
statusMessage: 'SMTP configuration missing. Please set SMTP_HOST for custom email provider.'
});
}
// Substitute variables in content
const processedSubject = substituteVariables(subject, processVariables);
const processedMessage = substituteVariables(message, processVariables);
const processedHtmlMessage = htmlMessage ? substituteVariables(htmlMessage, processVariables) : '';
// Resolve recipients
const recipients = await resolveRecipients(recipientType, recipientData, processVariables);
if (recipients.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'No valid recipients found'
});
}
// Create transporter
const transporter = createTransporter();
// Verify connection
try {
await transporter.verify();
} catch (error) {
console.error('SMTP connection failed:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to connect to email server'
});
}
// Prepare email content
let emailHtml;
if (messageFormat === 'html' && processedHtmlMessage) {
emailHtml = processedHtmlMessage;
} else {
emailHtml = generateEmailTemplate(notificationType, processedSubject, processedMessage, processName);
}
// Send emails
const results = [];
let successCount = 0;
let failureCount = 0;
for (const recipient of recipients) {
try {
const mailOptions = {
from: {
name: process.env.EMAIL_FROM_NAME || 'Corrad Process System',
address: process.env.EMAIL_USER
},
to: {
name: recipient.name,
address: recipient.email
},
subject: processedSubject,
text: processedMessage,
html: emailHtml,
priority: priority === 'high' ? 'high' : priority === 'low' ? 'low' : 'normal'
};
const info = await transporter.sendMail(mailOptions);
results.push({
recipient: recipient.email,
success: true,
messageId: info.messageId
});
successCount++;
console.log(`Email sent successfully to ${recipient.email}:`, info.messageId);
} catch (error) {
results.push({
recipient: recipient.email,
success: false,
error: error.message
});
failureCount++;
console.error(`Failed to send email to ${recipient.email}:`, error.message);
}
}
// Close transporter
transporter.close();
// Return results
return {
success: successCount > 0,
message: `Email notification processed. Sent: ${successCount}, Failed: ${failureCount}`,
sent: successCount,
failed: failureCount,
recipients: recipients.length,
details: results
};
} catch (error) {
console.error('Notification API Error:', error);
// Handle createError objects
if (error.statusCode) {
throw error;
}
// Handle other errors
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to send notification'
});
}
});

View File

@ -6730,6 +6730,11 @@ node-releases@^2.0.13:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==
nodemailer@^7.0.5:
version "7.0.5"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-7.0.5.tgz#3fa6607cab42401e4bedac9297e0aaa9ff84544b"
integrity sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==
nopt@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"