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)
|
||||
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('/assets', require('../../modules/assets/assets.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('/reports', require('../../modules/reports/reports.routes'));
|
||||
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