Skip to content

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:

{
  "variables": {
    "name": "Ijas",
    "invoiceNumber": "INV-2024-001",
    "dueDate": "2024-07-15"
  }
}

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)

  1. Templates define placeholders
  2. Events provide variables
  3. Validate before queue
  4. Render only in workers
  5. 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 {
  id
  channel
  title
  body
  variableSchema
  actions: ActionDefinition[]
}

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

When creating a user notification:

UserNotification {
  id
  templateId
  variables
  actions   // JSONB
}

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:

const resolvedActions = renderJsonTemplate(
  template.actions,
  input.variables
);

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

  1. Actions are data
  2. Templates declare intent
  3. Variables resolve once
  4. Clients only execute
  5. No UI logic in backend

🧩 Mapping this to YOUR current code

In your current file:

notification.body = body;

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.