GWM : Notification service

This commit is contained in:
Gowtham M 2026-07-21 11:06:17 +05:30
parent ca461f3413
commit 9585bff201
10 changed files with 490 additions and 0 deletions

View File

@ -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")
}

View 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();

View 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

View File

@ -0,0 +1,12 @@
const TEMPLATE_CODES = {
PO_SUBMIT_APPROVAL: 'PO_SUBMIT_APPROVAL',
};
const CHANNELS = {
EMAIL: 'EMAIL',
};
module.exports = {
TEMPLATE_CODES,
CHANNELS,
};

View 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,
};

View 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;

View 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,
};

View 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,
};

View File

@ -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'));

View 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,
};