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(/