diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6063a6e..a4f68b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/scripts/patch-notification-templates.sql b/scripts/patch-notification-templates.sql new file mode 100644 index 0000000..4655ef6 --- /dev/null +++ b/scripts/patch-notification-templates.sql @@ -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', + ' + + +

Hello {{approver_name}},

+

Purchase order {{po_number}} has been submitted and is waiting for your approval.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
PO Number{{po_number}}
PO Date{{po_date}}
Vendor{{vendor_name}}
Grand Total{{grand_total}}
Submitted By{{submitted_by}}
Remarks{{remarks}}
+

Review Purchase Order

+

If the button does not work, open this link:
{{po_link}}

+ +', + '[ + {"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(); diff --git a/src/docs/notifications-routes.yaml b/src/docs/notifications-routes.yaml new file mode 100644 index 0000000..e0d1cf4 --- /dev/null +++ b/src/docs/notifications-routes.yaml @@ -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 diff --git a/src/modules/notifications/notifications.constants.js b/src/modules/notifications/notifications.constants.js new file mode 100644 index 0000000..14ef33c --- /dev/null +++ b/src/modules/notifications/notifications.constants.js @@ -0,0 +1,12 @@ +const TEMPLATE_CODES = { + PO_SUBMIT_APPROVAL: 'PO_SUBMIT_APPROVAL', +}; + +const CHANNELS = { + EMAIL: 'EMAIL', +}; + +module.exports = { + TEMPLATE_CODES, + CHANNELS, +}; diff --git a/src/modules/notifications/notifications.controller.js b/src/modules/notifications/notifications.controller.js new file mode 100644 index 0000000..bef8470 --- /dev/null +++ b/src/modules/notifications/notifications.controller.js @@ -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, +}; diff --git a/src/modules/notifications/notifications.routes.js b/src/modules/notifications/notifications.routes.js new file mode 100644 index 0000000..cb632e4 --- /dev/null +++ b/src/modules/notifications/notifications.routes.js @@ -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; diff --git a/src/modules/notifications/notifications.service.js b/src/modules/notifications/notifications.service.js new file mode 100644 index 0000000..6ef9e0f --- /dev/null +++ b/src/modules/notifications/notifications.service.js @@ -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(//gi, ' ') + .replace(//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, +}; diff --git a/src/modules/notifications/notifications.validation.js b/src/modules/notifications/notifications.validation.js new file mode 100644 index 0000000..66579aa --- /dev/null +++ b/src/modules/notifications/notifications.validation.js @@ -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, +}; diff --git a/src/routes/v1/index.js b/src/routes/v1/index.js index 5387989..f9d1bfa 100644 --- a/src/routes/v1/index.js +++ b/src/routes/v1/index.js @@ -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')); diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 0000000..8c02285 --- /dev/null +++ b/src/services/email.service.js @@ -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, +};