Update notification setup¶
Perfect. This is a complete, production-grade design for a template variable validation + rendering system, wired cleanly into Clean Architecture + BullMQ.
This is the setup I’d actually ship.
🎯 Goal¶
You want to:
- Define notification templates with {{ variables }}
- Ensure correct variables are provided
- Catch errors before enqueueing
- Render safely at send-time
- Support retries, audits, and versioning
🧱 Core Architecture (high level)¶
Domain Event
↓
CreateNotificationUseCase
├─ Validate variables against template schema
├─ Persist notification (templateId + variables)
↓
Queue job (notification.send)
↓
Worker
├─ Load template
├─ Render with variables
└─ Send
1️⃣ Template definition (with variable schema)¶
NotificationTemplate table¶
NotificationTemplate {
id: string // invoice_due
version: number // 2
channel: 'email' | 'push'
subject: string
body: string // "Hi {{name}}..."
variableSchema: JSON // validation contract
isActive: boolean
}
Example record¶
{
"id": "invoice_due",
"version": 2,
"channel": "email",
"subject": "Invoice due reminder",
"body": "Hi {{ name }}, your invoice {{ invoiceNumber }} is due on {{ dueDate }}.",
"variableSchema": {
"type": "object",
"required": ["name", "invoiceNumber", "dueDate"],
"properties": {
"name": { "type": "string" },
"invoiceNumber": { "type": "string" },
"dueDate": { "type": "string", "format": "date" }
}
}
}
📌 Key idea
Templates declare what variables they require, not how to get them.
2️⃣ UserNotification (store variables, not rendered text)¶
UserNotification {
id: string
userId: string
templateId: string
templateVersion: number
variables: JSONB
status: 'pending' | 'sent' | 'failed'
error?: string
createdAt
}
Example:
3️⃣ Variable validation (before enqueueing) ✅¶
Use JSON Schema + Ajv¶
import Ajv from 'ajv';
const ajv = new Ajv({ allErrors: true });
export function validateVariables(
schema: object,
variables: Record<string, unknown>
) {
const validate = ajv.compile(schema);
if (!validate(variables)) {
throw new Error(
`Invalid notification variables: ${ajv.errorsText(validate.errors)}`
);
}
}
4️⃣ CreateNotificationUseCase (clean & safe)¶
export class CreateNotificationUseCase {
constructor(
private readonly templateRepo: TemplateRepository,
private readonly notificationRepo: NotificationRepository,
private readonly jobQueue: JobQueue
) {}
async execute(input: {
userId: string;
templateId: string;
variables: Record<string, unknown>;
}) {
const template = await this.templateRepo.getActive(input.templateId);
validateVariables(template.variableSchema, input.variables);
const notification = await this.notificationRepo.create({
userId: input.userId,
templateId: template.id,
templateVersion: template.version,
variables: input.variables,
status: 'pending',
});
await this.jobQueue.enqueue('notification.send', {
notificationId: notification.id,
});
return notification.id;
}
}
📌 Why this is important
- Broken notifications never enter the queue
- Workers don’t need to guess
- Errors fail fast, near the source
5️⃣ Rendering (ONLY in the worker)¶
Use Handlebars (strict mode)¶
import Handlebars from 'handlebars';
export function renderTemplate(
template: string,
variables: Record<string, unknown>
): string {
const compiled = Handlebars.compile(template, {
strict: true, // 🔥 throws if variable missing
noEscape: true,
});
return compiled(variables);
}
6️⃣ BullMQ worker (email example)¶
new Worker('notification-queue', async job => {
const { notificationId } = job.data;
const notification = await notificationRepo.get(notificationId);
const template = await templateRepo.getVersion(
notification.templateId,
notification.templateVersion
);
try {
const body = renderTemplate(template.body, notification.variables);
await emailService.send({
to: notification.userId,
subject: template.subject,
body
});
await notificationRepo.markSent(notificationId);
} catch (err) {
await notificationRepo.markFailed(notificationId, err.message);
throw err; // allow retry
}
});
7️⃣ Why this design is¶
best practice¶
✅ Validation happens¶
before enqueue¶
No poison jobs.
✅ Rendering happens¶
at send-time¶
Supports:
- retries
- template updates
- auditing
✅ Variables are persisted¶
You can:
- resend notifications
- inspect what user saw
- debug production issues
✅ Templates are versioned¶
Old notifications render with old templates.
🚫 What this avoids (very important)¶
❌ String replace hacks
❌ Rendering in controllers
❌ Losing variable context
❌ Silent failures
❌ Broken retries
🧠 Golden rules (remember these)¶
- Templates define placeholders
- Events provide variables
- Validate before queue
- Render only in workers
- Persist variables always
🚀 Optional next steps (I highly recommend)¶
I can next:
- add template preview tooling
- add i18n support
- build a variable auto-resolver
- add per-channel fallback templates
- design a notification retry UI
Just tell me which one you want next.
Add action button to notifications¶
Great question — action buttons are not a UI concern only; they must be modeled correctly in backend + template design, otherwise they become brittle.
I’ll give you the best-practice, production-grade approach, and then map it directly to your existing notification system (templates + variables + clean architecture).
🧠 First principle (anchor this)¶
Notifications describe actions.
Clients decide how to render them.
So:
- Backend defines what actions are possible
- Frontend/mobile decides how buttons look
🎯 What you want to achieve¶
A notification like:
“Your invoice is due”
With buttons:
- Pay Now
- View Invoice
✅ Correct Architecture (high-level)¶
NotificationTemplate
├─ content (body, title)
├─ actions[] ← declarative
UserNotification
├─ variables
├─ resolvedActions[] (optional)
Client
├─ renders buttons
├─ handles navigation / API calls
🧱 Step 1: Define actions in the TEMPLATE (not in code)¶
NotificationTemplate schema (recommended)¶
ActionDefinition (backend contract)¶
type ActionDefinition = {
id: string; // "pay_now"
label: string; // "Pay Now"
type: 'deeplink' | 'api' | 'route';
payload: JsonObject; // can contain variables
primary?: boolean;
};
Example template record¶
{
"id": "invoice_due",
"body": "Your invoice {{ invoiceNumber }} is due",
"actions": [
{
"id": "pay_now",
"label": "Pay Now",
"type": "deeplink",
"payload": {
"url": "app://payments/{{ invoiceId }}"
},
"primary": true
},
{
"id": "view_invoice",
"label": "View Invoice",
"type": "route",
"payload": {
"screen": "InvoiceDetails",
"params": {
"invoiceId": "{{ invoiceId }}"
}
}
}
]
}
📌 Important
- Actions are data
- Variables ({{ invoiceId }}) are allowed
- No logic in templates
🧱 Step 2: Store actions with UserNotification (optional but recommended)¶
When creating a user notification:
You have two valid choices:
Option A (BEST): Resolve actions at creation time¶
- Store fully resolved actions
- Safer for replay & audit
Option B: Resolve at read-time¶
- Flexible
- Requires rendering logic later
I recommend Option A.
🧱 Step 3: Resolve variables in actions (backend)¶
Just like body rendering — but for JSON.
Safe variable resolution function¶
function renderJsonTemplate<T extends object>(
template: T,
variables: Record<string, unknown>
): T {
const json = JSON.stringify(template);
const rendered = renderTemplate(json, variables); // Handlebars
return JSON.parse(rendered);
}
Now during notification creation:
Store resolvedActions in DB.
🧱 Step 4: Return actions in GetNotificationsForUserQuery¶
Extend DTO¶
NotificationDto {
id
title
body
actions: {
id: string
label: string
type: string
payload: JsonObject
primary?: boolean
}[]
}
Backend returns¶
data only¶
{
"body": "Your invoice INV-001 is due",
"actions": [
{
"id": "pay_now",
"label": "Pay Now",
"type": "deeplink",
"payload": {
"url": "app://payments/123"
},
"primary": true
}
]
}
🧱 Step 5: Frontend / Mobile rendering (clean separation)¶
Mobile app logic¶
actions.forEach(action => {
switch (action.type) {
case 'deeplink':
openDeepLink(action.payload.url);
break;
case 'route':
navigate(action.payload.screen, action.payload.params);
break;
case 'api':
callApi(action.payload.endpoint);
break;
}
});
📌 Frontend:
- does not parse templates
- does not guess behavior
- only executes declared actions
🚫 What NOT to do (very important)¶
❌ Hardcode buttons in frontend
❌ Infer action from message text
❌ Put UI labels in code
❌ Store rendered HTML buttons
❌ Store logic in templates
🧠 Why this approach is the best¶
| Concern | Solved |
|---|---|
| Backend-driven UX | ✅ |
| Localization-ready | ✅ |
| Audit & replay | ✅ |
| Mobile & web reuse | ✅ |
| Versioning | ✅ |
| No frontend guesswork | ✅ |
🔥 Final golden rules¶
- Actions are data
- Templates declare intent
- Variables resolve once
- Clients only execute
- No UI logic in backend
🧩 Mapping this to YOUR current code¶
In your current file:
You should:
- ❌ stop doing string.replace
- ✅ return actions from DB
- ✅ render body + actions centrally (use-case or worker)
If you want, next I can:
- refactor your existing query to support actions
- design the DB schema migration
- wire this into your BullMQ notification worker
- give you mobile UI examples
Just tell me which one you want next.