GWM : Notification service
This commit is contained in:
parent
ca461f3413
commit
9585bff201
@ -1051,3 +1051,23 @@ model email_settings {
|
|||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model notification_templates {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
code String @unique @db.VarChar(80)
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
channel String @default("EMAIL") @db.VarChar(20)
|
||||||
|
subject String @db.VarChar(500)
|
||||||
|
html_body String
|
||||||
|
placeholders Json @default("[]") @db.JsonB
|
||||||
|
description String?
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_by BigInt?
|
||||||
|
updated_by BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@index([channel], map: "idx_notification_templates_channel")
|
||||||
|
@@index([is_active], map: "idx_notification_templates_is_active")
|
||||||
|
}
|
||||||
|
|||||||
95
scripts/patch-notification-templates.sql
Normal file
95
scripts/patch-notification-templates.sql
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
-- Notification templates: HTML email bodies + placeholder metadata
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
code VARCHAR(80) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
channel VARCHAR(20) NOT NULL DEFAULT 'EMAIL',
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
html_body TEXT NOT NULL,
|
||||||
|
placeholders JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_by BIGINT,
|
||||||
|
updated_by BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_templates_channel ON notification_templates (channel);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_templates_is_active ON notification_templates (is_active);
|
||||||
|
|
||||||
|
INSERT INTO notification_templates (
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
channel,
|
||||||
|
subject,
|
||||||
|
html_body,
|
||||||
|
placeholders,
|
||||||
|
description,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'PO_SUBMIT_APPROVAL',
|
||||||
|
'Purchase Order Submitted for Approval',
|
||||||
|
'EMAIL',
|
||||||
|
'PO {{po_number}} submitted for approval',
|
||||||
|
'<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; color: #222; line-height: 1.5;">
|
||||||
|
<p>Hello {{approver_name}},</p>
|
||||||
|
<p>Purchase order <strong>{{po_number}}</strong> has been submitted and is waiting for your approval.</p>
|
||||||
|
<table style="border-collapse: collapse; margin: 16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0;"><strong>PO Number</strong></td>
|
||||||
|
<td style="padding: 4px 0;">{{po_number}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0;"><strong>PO Date</strong></td>
|
||||||
|
<td style="padding: 4px 0;">{{po_date}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0;"><strong>Vendor</strong></td>
|
||||||
|
<td style="padding: 4px 0;">{{vendor_name}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0;"><strong>Grand Total</strong></td>
|
||||||
|
<td style="padding: 4px 0;">{{grand_total}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0;"><strong>Submitted By</strong></td>
|
||||||
|
<td style="padding: 4px 0;">{{submitted_by}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0;"><strong>Remarks</strong></td>
|
||||||
|
<td style="padding: 4px 0;">{{remarks}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p><a href="{{po_link}}" style="display: inline-block; padding: 10px 16px; background: #1f4b99; color: #fff; text-decoration: none; border-radius: 4px;">Review Purchase Order</a></p>
|
||||||
|
<p style="color: #666; font-size: 12px;">If the button does not work, open this link:<br/>{{po_link}}</p>
|
||||||
|
</body>
|
||||||
|
</html>',
|
||||||
|
'[
|
||||||
|
{"key": "approver_name", "description": "Approver full name"},
|
||||||
|
{"key": "po_number", "description": "Purchase order number"},
|
||||||
|
{"key": "po_date", "description": "Purchase order date"},
|
||||||
|
{"key": "vendor_name", "description": "Vendor name"},
|
||||||
|
{"key": "grand_total", "description": "PO grand total"},
|
||||||
|
{"key": "submitted_by", "description": "User who submitted the PO"},
|
||||||
|
{"key": "remarks", "description": "PO remarks"},
|
||||||
|
{"key": "po_link", "description": "Frontend deep link to the PO"}
|
||||||
|
]'::jsonb,
|
||||||
|
'Sent to users with PURCHASE_ORDER approve permission when a PO is submitted',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
channel = EXCLUDED.channel,
|
||||||
|
subject = EXCLUDED.subject,
|
||||||
|
html_body = EXCLUDED.html_body,
|
||||||
|
placeholders = EXCLUDED.placeholders,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
is_active = true,
|
||||||
|
deleted_at = NULL,
|
||||||
|
updated_at = NOW();
|
||||||
44
src/docs/notifications-routes.yaml
Normal file
44
src/docs/notifications-routes.yaml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
tags:
|
||||||
|
- name: Notifications
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
TriggerNotificationBody:
|
||||||
|
type: object
|
||||||
|
required: [template_code, po_id]
|
||||||
|
properties:
|
||||||
|
template_code:
|
||||||
|
type: string
|
||||||
|
enum: [PO_SUBMIT_APPROVAL]
|
||||||
|
example: PO_SUBMIT_APPROVAL
|
||||||
|
po_id:
|
||||||
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
example: 42
|
||||||
|
description: Purchase order id to notify approvers about
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/notifications/trigger:
|
||||||
|
post:
|
||||||
|
tags: [Notifications]
|
||||||
|
summary: Trigger a templated notification email
|
||||||
|
description: >
|
||||||
|
Explicitly sends the selected email template. For `PO_SUBMIT_APPROVAL`,
|
||||||
|
emails all active users with `PURCHASE_ORDER:approve`. PO must be in
|
||||||
|
`PENDING_APPROVAL`. RBAC: `PURCHASE_ORDER` edit.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/TriggerNotificationBody' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Notification email triggered
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
|
||||||
|
'409':
|
||||||
|
description: PO is not in PENDING_APPROVAL
|
||||||
|
'422':
|
||||||
|
description: No approvers found
|
||||||
|
'503':
|
||||||
|
description: SMTP not configured or all sends failed
|
||||||
12
src/modules/notifications/notifications.constants.js
Normal file
12
src/modules/notifications/notifications.constants.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const TEMPLATE_CODES = {
|
||||||
|
PO_SUBMIT_APPROVAL: 'PO_SUBMIT_APPROVAL',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNELS = {
|
||||||
|
EMAIL: 'EMAIL',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TEMPLATE_CODES,
|
||||||
|
CHANNELS,
|
||||||
|
};
|
||||||
12
src/modules/notifications/notifications.controller.js
Normal file
12
src/modules/notifications/notifications.controller.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const asyncHandler = require('../../utils/asyncHandler');
|
||||||
|
const ApiResponse = require('../../utils/ApiResponse');
|
||||||
|
const service = require('./notifications.service');
|
||||||
|
|
||||||
|
const trigger = asyncHandler(async (req, res) => {
|
||||||
|
const data = await service.triggerNotification(req.body, req.user?.id, req.id);
|
||||||
|
res.json(new ApiResponse(200, data, 'Notification email triggered successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
trigger,
|
||||||
|
};
|
||||||
19
src/modules/notifications/notifications.routes.js
Normal file
19
src/modules/notifications/notifications.routes.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const authenticate = require('../../middlewares/auth.middleware');
|
||||||
|
const authorize = require('../../middlewares/rbac.middleware');
|
||||||
|
const validate = require('../../middlewares/validate.middleware');
|
||||||
|
const controller = require('./notifications.controller');
|
||||||
|
const { triggerNotificationSchema } = require('./notifications.validation');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/trigger',
|
||||||
|
authorize('PURCHASE_ORDER', 'edit'),
|
||||||
|
validate(triggerNotificationSchema),
|
||||||
|
controller.trigger
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
224
src/modules/notifications/notifications.service.js
Normal file
224
src/modules/notifications/notifications.service.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
const prisma = require('../../config/prisma');
|
||||||
|
const env = require('../../config/env');
|
||||||
|
const logger = require('../../config/logger');
|
||||||
|
const ApiError = require('../../utils/ApiError');
|
||||||
|
const { sendEmail } = require('../../services/email.service');
|
||||||
|
const { TEMPLATE_CODES } = require('./notifications.constants');
|
||||||
|
|
||||||
|
const PLACEHOLDER_REGEX = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
||||||
|
|
||||||
|
const getTemplateByCode = async (code) => {
|
||||||
|
const template = await prisma.notification_templates.findFirst({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
is_active: true,
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!template) {
|
||||||
|
throw new ApiError(404, `Notification template not found: ${code}`);
|
||||||
|
}
|
||||||
|
return template;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPlaceholders = (content, data = {}) => {
|
||||||
|
if (content == null) return content;
|
||||||
|
return String(content).replace(PLACEHOLDER_REGEX, (_match, key) => {
|
||||||
|
const value = data[key];
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripHtml = (html) =>
|
||||||
|
String(html || '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load template by code, replace {{placeholders}}, and send via email service.
|
||||||
|
*/
|
||||||
|
const sendTemplatedEmail = async ({ templateCode, to, data = {}, context = {} }) => {
|
||||||
|
const template = await getTemplateByCode(templateCode);
|
||||||
|
const subject = renderPlaceholders(template.subject, data);
|
||||||
|
const html = renderPlaceholders(template.html_body, data);
|
||||||
|
const text = stripHtml(html);
|
||||||
|
|
||||||
|
await sendEmail({ to, subject, html, text });
|
||||||
|
logger.info('Templated email sent', { templateCode, to, ...context });
|
||||||
|
return { subject, to };
|
||||||
|
};
|
||||||
|
|
||||||
|
const listUsersWithPermission = async (moduleCode, action) => {
|
||||||
|
return prisma.users.findMany({
|
||||||
|
where: {
|
||||||
|
deleted_at: null,
|
||||||
|
is_active: true,
|
||||||
|
status: 'active',
|
||||||
|
email: { not: null },
|
||||||
|
user_roles: {
|
||||||
|
some: {
|
||||||
|
roles: {
|
||||||
|
deleted_at: null,
|
||||||
|
is_active: true,
|
||||||
|
role_permissions: {
|
||||||
|
some: {
|
||||||
|
permissions: {
|
||||||
|
action,
|
||||||
|
is_active: true,
|
||||||
|
modules: {
|
||||||
|
code: moduleCode,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
full_name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return '';
|
||||||
|
return num.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateOnly = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPoFrontendLink = (poId) => {
|
||||||
|
const base = env.FRONTEND_URL.replace(/\/$/, '');
|
||||||
|
return `${base}/purchase-orders/${poId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPoForNotification = async (poId) => {
|
||||||
|
const po = await prisma.purchase_orders.findFirst({
|
||||||
|
where: { id: BigInt(poId), deleted_at: null },
|
||||||
|
include: {
|
||||||
|
vendors: { select: { id: true, vendor_code: true, vendor_name: true } },
|
||||||
|
users_purchase_orders_created_byTousers: { select: { id: true, full_name: true } },
|
||||||
|
users_purchase_orders_updated_byTousers: { select: { id: true, full_name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!po) throw new ApiError(404, 'Purchase order not found');
|
||||||
|
return po;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send PO_SUBMIT_APPROVAL emails to all users with PURCHASE_ORDER:approve.
|
||||||
|
* Intended for explicit FE trigger (button), not automatic on submit.
|
||||||
|
*/
|
||||||
|
const triggerPoSubmitApproval = async (poId) => {
|
||||||
|
const po = await getPoForNotification(poId);
|
||||||
|
|
||||||
|
if (po.status !== 'PENDING_APPROVAL') {
|
||||||
|
throw new ApiError(409, 'PO must be in PENDING_APPROVAL status to notify approvers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvers = await listUsersWithPermission('PURCHASE_ORDER', 'approve');
|
||||||
|
if (!approvers.length) {
|
||||||
|
throw new ApiError(422, 'No active approvers found with PURCHASE_ORDER approve permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseData = {
|
||||||
|
po_number: po.po_number || '',
|
||||||
|
po_date: formatDateOnly(po.po_date),
|
||||||
|
vendor_name: po.vendors?.vendor_name || '',
|
||||||
|
grand_total: formatMoney(po.grand_total),
|
||||||
|
submitted_by:
|
||||||
|
po.users_purchase_orders_updated_byTousers?.full_name ||
|
||||||
|
po.users_purchase_orders_created_byTousers?.full_name ||
|
||||||
|
'',
|
||||||
|
remarks: po.remarks || '',
|
||||||
|
po_link: buildPoFrontendLink(po.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipients = [];
|
||||||
|
for (const approver of approvers) {
|
||||||
|
try {
|
||||||
|
await sendTemplatedEmail({
|
||||||
|
templateCode: TEMPLATE_CODES.PO_SUBMIT_APPROVAL,
|
||||||
|
to: approver.email,
|
||||||
|
data: {
|
||||||
|
...baseData,
|
||||||
|
approver_name: approver.full_name || 'Approver',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
poId: String(po.id),
|
||||||
|
approverUserId: String(approver.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
recipients.push({
|
||||||
|
user_id: String(approver.id),
|
||||||
|
email: approver.email,
|
||||||
|
full_name: approver.full_name,
|
||||||
|
sent: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('PO submit approval email failed for recipient', {
|
||||||
|
error: err.message,
|
||||||
|
poId: String(po.id),
|
||||||
|
approverUserId: String(approver.id),
|
||||||
|
email: approver.email,
|
||||||
|
});
|
||||||
|
recipients.push({
|
||||||
|
user_id: String(approver.id),
|
||||||
|
email: approver.email,
|
||||||
|
full_name: approver.full_name,
|
||||||
|
sent: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentCount = recipients.filter((r) => r.sent).length;
|
||||||
|
if (sentCount === 0) {
|
||||||
|
throw new ApiError(503, 'Failed to send approval notification email. Check SMTP settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
template_code: TEMPLATE_CODES.PO_SUBMIT_APPROVAL,
|
||||||
|
po_id: String(po.id),
|
||||||
|
po_number: po.po_number,
|
||||||
|
sent_count: sentCount,
|
||||||
|
failed_count: recipients.length - sentCount,
|
||||||
|
recipients,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerNotification = async (payload) => {
|
||||||
|
const { template_code: templateCode, po_id: poId } = payload;
|
||||||
|
|
||||||
|
if (templateCode === TEMPLATE_CODES.PO_SUBMIT_APPROVAL) {
|
||||||
|
return triggerPoSubmitApproval(poId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(400, `Unsupported notification template: ${templateCode}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TEMPLATE_CODES,
|
||||||
|
getTemplateByCode,
|
||||||
|
renderPlaceholders,
|
||||||
|
sendTemplatedEmail,
|
||||||
|
listUsersWithPermission,
|
||||||
|
triggerPoSubmitApproval,
|
||||||
|
triggerNotification,
|
||||||
|
};
|
||||||
13
src/modules/notifications/notifications.validation.js
Normal file
13
src/modules/notifications/notifications.validation.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
const { TEMPLATE_CODES } = require('./notifications.constants');
|
||||||
|
|
||||||
|
const triggerNotificationSchema = Joi.object({
|
||||||
|
template_code: Joi.string()
|
||||||
|
.valid(...Object.values(TEMPLATE_CODES))
|
||||||
|
.required(),
|
||||||
|
po_id: Joi.alternatives().try(Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
triggerNotificationSchema,
|
||||||
|
};
|
||||||
@ -10,6 +10,7 @@ router.use('/purchase-orders', require('../../modules/purchase-orders/purchase-o
|
|||||||
router.use('/grn', require('../../modules/grn/grn.routes'));
|
router.use('/grn', require('../../modules/grn/grn.routes'));
|
||||||
router.use('/assets', require('../../modules/assets/assets.routes'));
|
router.use('/assets', require('../../modules/assets/assets.routes'));
|
||||||
router.use('/settings', require('../../modules/settings/settings.routes'));
|
router.use('/settings', require('../../modules/settings/settings.routes'));
|
||||||
|
router.use('/notifications', require('../../modules/notifications/notifications.routes'));
|
||||||
router.use('/audit-logs', require('../../modules/audit-logs/audit-logs.routes'));
|
router.use('/audit-logs', require('../../modules/audit-logs/audit-logs.routes'));
|
||||||
router.use('/reports', require('../../modules/reports/reports.routes'));
|
router.use('/reports', require('../../modules/reports/reports.routes'));
|
||||||
router.use('/masters', require('../../modules/masters'));
|
router.use('/masters', require('../../modules/masters'));
|
||||||
|
|||||||
50
src/services/email.service.js
Normal file
50
src/services/email.service.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const { sendMail } = require('../utils/email');
|
||||||
|
const logger = require('../config/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injectable email sender. Call from any module without owning SMTP details.
|
||||||
|
* SMTP is loaded from Settings → Email (`email_settings`).
|
||||||
|
*/
|
||||||
|
const sendEmail = async ({ to, subject, html, text }) => {
|
||||||
|
const recipients = Array.isArray(to) ? to.filter(Boolean) : [to].filter(Boolean);
|
||||||
|
if (!recipients.length) {
|
||||||
|
throw new Error('Email recipient is required');
|
||||||
|
}
|
||||||
|
if (!subject) {
|
||||||
|
throw new Error('Email subject is required');
|
||||||
|
}
|
||||||
|
if (!html && !text) {
|
||||||
|
throw new Error('Email html or text body is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text: text || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as sendEmail but never throws — use for non-critical notifications
|
||||||
|
* so business workflows (e.g. PO submit) are not blocked by SMTP failures.
|
||||||
|
*/
|
||||||
|
const sendEmailSafe = async (payload, context = {}) => {
|
||||||
|
try {
|
||||||
|
await sendEmail(payload);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Email send failed', {
|
||||||
|
error: err.message,
|
||||||
|
to: payload?.to,
|
||||||
|
subject: payload?.subject,
|
||||||
|
...context,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendEmail,
|
||||||
|
sendEmailSafe,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user