Skip to main content
Version: 1.0

8. Notification Templates

8.1 Template model

Entity: AppNotificationTemplate (table AppNotificationTemplates, framework-owned, namespace-shimmed into Shumoul.Domain.Entities.Notifications).

FieldTypeNotes
EventKeystring(100), requiredGroups templates by business event — see NotificationEventKeys constants in Appendix
Namestring(200), requiredArabic display name (bilingual primary)
FNamestring(200)English display name
SubjectTemplatestringScriban template — used by Email; ignored by channels with no subject concept
BodyTemplatestring, requiredScriban template — the message body for every channel except WhatsApp's structured components
ChannelNotificationChannel, default InAppOne template row per channel — a single event typically has multiple template rows, one per enabled channel
LanguageCodestring(10)?, nullablenull = applies to all languages (matches any resolved language as a fallback). A specific value ("ar", "en") matches only that language
WhatsAppTemplateNamestring(100)?Meta-approved template name — required only for Channel = WhatsApp
WhatsAppLanguageCodestring(10)?WhatsApp-specific language fallback (see §7.3)
WhatsAppBodyParamsstring? (JSON array)Scriban expressions mapped to WhatsApp template {{1}}, {{2}}, … positional parameters

8.2 Template selection algorithm

For a given EventKey and the language resolved for this dispatch (request.LanguageCode → current thread culture → "ar"):

  1. Load all AppNotificationTemplate rows matching EventKey + the channel being dispatched.
  2. Prefer a row whose LanguageCode exactly matches the resolved language.
  3. If none, fall back to a row whose LanguageCode is null (applies to all languages).
  4. If still none, that channel is silently skipped for this dispatch — this is not an error; it means no template has been configured for that event/channel/language combination yet.

This selection runs independently per channel — a single DispatchNotificationRequest can render a different subject/body per channel (e.g. a longer Email body vs. a short SMS body), because each channel has its own template row.

8.3 Rendering — Scriban

Rendering is done by ScribanTemplateRenderer (Shumoul.Notification.Core), using the Scriban templating engine with one specific customization: both the imported model and the template context use a member => member.Name renamer, which preserves original C# casing instead of Scriban's default snake_case conversion. This means template variables use camelCase exactly matching the C# property name:

{{ customerName }} ✅ correct — matches request.Data.CustomerName's camelCase-preserved name
{{ customer_name }} ❌ wrong — this is Scriban's *default* behavior, not what this renderer does

request.Data (an object) is passed as the model. If it is an IDictionary<string, object>, each key/value is added directly to the Scriban ScriptObject. Otherwise, it is imported via scriptObject.Import(model, renamer: member => member.Name) — i.e. any anonymous object or POCO's public properties become template variables.

8.4 Worked example

Given an AppNotificationTemplate row:

{
"eventKey": "PurchaseInvoicePosted",
"channel": "Email",
"languageCode": "en",
"subjectTemplate": "Invoice {{ invoiceNumber }} has been posted",
"bodyTemplate": "Hello {{ customerName }},\n\nYour invoice {{ invoiceNumber }} for {{ totalAmount }} {{ currencyCode }} has been posted on {{ postedDate }}.\n\nThank you."
}

Dispatched with:

{
"eventKey": "PurchaseInvoicePosted",
"recipientUserId": "5f1a3b2c-0000-0000-0000-000000000001",
"data": {
"customerName": "Ahmed Al-Otaibi",
"invoiceNumber": "INV-2026-00451",
"totalAmount": 12500.00,
"currencyCode": "SAR",
"postedDate": "2026-07-04"
}
}

Renders to:

Subject: Invoice INV-2026-00451 has been posted Body: Hello Ahmed Al-Otaibi,

Your invoice INV-2026-00451 for 12500 SAR has been posted on 2026-07-04.

Thank you.

A parallel Channel: WhatsApp template row for the same EventKey might instead carry:

{
"whatsAppTemplateName": "invoice_posted_en",
"whatsAppLanguageCode": "en",
"whatsAppBodyParams": "[\"{{ customerName }}\", \"{{ invoiceNumber }}\", \"{{ totalAmount }}\"]"
}

Each expression in whatsAppBodyParams is rendered independently and mapped positionally to the Meta template's {{1}}, {{2}}, {{3}} placeholders — bodyTemplate/subjectTemplate are not used for WhatsApp dispatch at all.

8.5 Localization

  • One AppNotificationTemplate row per (EventKey, Channel, LanguageCode) combination lets every business event carry fully independent Arabic and English copy per channel.
  • A LanguageCode = null row acts as a catch-all fallback for any language not otherwise covered — useful when only one language variant has been authored so far, without blocking dispatch for other languages.
  • Bilingual fields on the template itself (Name/FName) are the management-UI display name, not the rendered content — they never appear in a delivered notification.

8.6 Fallback behavior summary

SituationResult
No NotificationEventConfiguration row for EventKeyAll channels enabled (safe default)
NotificationEventConfiguration.Enable{Channel} = falseThat channel is skipped entirely, no template lookup even attempted
No template row for EventKey + Channel in any languageThat channel is skipped, no error raised
Template row exists only with LanguageCode = nullUsed for every resolved language
Malformed WhatsAppBodyParams JSONWarning logged, dispatch proceeds with zero body parameters

8.7 Best practices

  1. Always create a LanguageCode = null fallback template alongside any language-specific ones for a new event, so dispatch never silently skips a channel for an unanticipated language.
  2. Keep Data payloads flat where practical — Scriban's member.Name renamer works cleanly on simple POCOs and dictionaries; deeply nested objects are supported but harder to author templates against.
  3. For WhatsApp, WhatsAppBodyParams order must exactly match the Meta-approved template's parameter order — there is no name-based binding at the WhatsApp API level, only positional.
  4. Never put PII you don't want logged into WhatsAppTemplateName itself (it is logged, unlike parameter values) — keep template names purely descriptive ("order_confirmation_en", not something containing a customer name).