8. Notification Templates
8.1 Template model
Entity: AppNotificationTemplate (table AppNotificationTemplates, framework-owned, namespace-shimmed into
Shumoul.Domain.Entities.Notifications).
| Field | Type | Notes |
|---|---|---|
EventKey | string(100), required | Groups templates by business event — see NotificationEventKeys constants in Appendix |
Name | string(200), required | Arabic display name (bilingual primary) |
FName | string(200) | English display name |
SubjectTemplate | string | Scriban template — used by Email; ignored by channels with no subject concept |
BodyTemplate | string, required | Scriban template — the message body for every channel except WhatsApp's structured components |
Channel | NotificationChannel, default InApp | One template row per channel — a single event typically has multiple template rows, one per enabled channel |
LanguageCode | string(10)?, nullable | null = applies to all languages (matches any resolved language as a fallback). A specific value ("ar", "en") matches only that language |
WhatsAppTemplateName | string(100)? | Meta-approved template name — required only for Channel = WhatsApp |
WhatsAppLanguageCode | string(10)? | WhatsApp-specific language fallback (see §7.3) |
WhatsAppBodyParams | string? (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"):
- Load all
AppNotificationTemplaterows matchingEventKey+ the channel being dispatched. - Prefer a row whose
LanguageCodeexactly matches the resolved language. - If none, fall back to a row whose
LanguageCodeisnull(applies to all languages). - 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
AppNotificationTemplaterow per (EventKey,Channel,LanguageCode) combination lets every business event carry fully independent Arabic and English copy per channel. - A
LanguageCode = nullrow 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
| Situation | Result |
|---|---|
No NotificationEventConfiguration row for EventKey | All channels enabled (safe default) |
NotificationEventConfiguration.Enable{Channel} = false | That channel is skipped entirely, no template lookup even attempted |
No template row for EventKey + Channel in any language | That channel is skipped, no error raised |
Template row exists only with LanguageCode = null | Used for every resolved language |
Malformed WhatsAppBodyParams JSON | Warning logged, dispatch proceeds with zero body parameters |
8.7 Best practices
- Always create a
LanguageCode = nullfallback template alongside any language-specific ones for a new event, so dispatch never silently skips a channel for an unanticipated language. - Keep
Datapayloads flat where practical — Scriban'smember.Namerenamer works cleanly on simple POCOs and dictionaries; deeply nested objects are supported but harder to author templates against. - For WhatsApp,
WhatsAppBodyParamsorder must exactly match the Meta-approved template's parameter order — there is no name-based binding at the WhatsApp API level, only positional. - Never put PII you don't want logged into
WhatsAppTemplateNameitself (it is logged, unlike parameter values) — keep template names purely descriptive ("order_confirmation_en", not something containing a customer name).