7. Notification Channels
The NotificationChannel enum (Shumoul.Notification.Contracts) defines six channels:
Email=1, Sms=2, Push=3, InApp=4, SignalR=5, WhatsApp=6.
7.1 Email
| Purpose | Transactional and business email (invoices posted, subscriptions renewed, password resets, etc.) |
| Implementing class | EmailNotificationService (Shumoul.Notification.Core/Notifications/) |
| Interface | IEmailNotificationService |
| Underlying transport | IMailService (SMTP), wrapped with ITemplateRenderer |
| Provider failover | Layer-1 abstraction: INotificationProviderResolver returns priority-ordered, enabled IEmailProvider implementations (e.g. ZohoEmailProvider); tracked by InMemoryProviderHealthMonitor (5-consecutive-failure circuit breaker, 30-minute auto-recovery) |
| Dispatch-layer wrapper | EmailChannelProvider (INotificationChannelProvider) wraps IEmailNotificationService — it does not bypass layer 1 |
| Configuration | MailSettings section in appsettings.json: DisplayName, EnableVerification, From, Mail, Host, Password (environment-sourced), Port, UserName |
7.2 SMS
| Purpose | OTPs, short transactional alerts |
| Implementing class | SmsNotificationService (Shumoul.Notification.Core/Notifications/) |
| Interface | ISmsNotificationService |
| Underlying transport | ISendSMSService, wrapped with ITemplateRenderer |
| Provider failover | Layer-1 abstraction, e.g. FourJawalySmsProvider |
| Dispatch-layer wrapper | SmsChannelProvider |
| Configuration | No dedicated SMS settings section was found in the tracked Shumoul.Api\appsettings.json — SMS provider credentials are environment-specific. See Chapter 15 — Configuration for what is/isn't tracked in git. |
7.3 WhatsApp
WhatsApp is the one channel not routed through INotificationChannelProviderResolver — it is invoked
directly and inline by NotificationDispatchService, because it needs Meta-specific business template
handling that the generic channel-provider contract does not model (template name, template language,
positional body parameters).
| Purpose | Business-approved WhatsApp template messages (order confirmations, OTPs, activation messages) |
| Implementing class | WhatsAppNotificationSender (Shumoul.Infrastructure\Services\Notifications\, ERP-owned) |
| Interface | IWhatsAppNotificationSender |
| Underlying transport | IWhatsAppService (Shumoul.Saas.WhatsAppIntegration) → Meta Graph API |
Guard chain (in order — any failure logs and returns silently, never throws):
SaaSNotificationSettings.EnableWhatsApp && EnableWhatsAppNotificationsmust both be true.EnableWhatsAppTemplatesmust be true.AppNotificationTemplate.WhatsAppTemplateNamemust be non-empty.WhatsAppCloudApiOptions.AccessTokenandPhoneNumberIdmust both be configured.- The recipient phone must normalize successfully (see below).
Language resolution: request.LanguageCode (per-dispatch override) → template.WhatsAppLanguageCode →
"ar" default.
Body parameters: AppNotificationTemplate.WhatsAppBodyParams is a JSON array of Scriban expression
strings (e.g. ["{{ customerName }}", "{{ orderNumber }}"]). Each expression is rendered independently
against request.Data and mapped 1:1 to WhatsApp template {{1}}, {{2}}, … positional parameters inside a
single "body" component. A malformed JSON array logs a warning and sends zero parameters rather than
failing the whole dispatch.
Phone normalization (Saudi E.164, no leading +):
| Input | Normalized |
|---|---|
0549000191 | 966549000191 |
+966549000191 / 966549000191 | 966549000191 |
549000191 | 966549000191 |
| Anything else | normalization fails → dispatch skipped, warning logged |
Audit logging: every WhatsApp send creates a TenantNotificationLog row via
ITenantNotificationLogService.CreatePendingAsync(...) (tenant ID resolved from the "TenantId" JWT claim),
transitioned to MarkSentAsync/MarkFailedAsync after the Graph API call returns. The logged message body
is never the real content — it is the literal string "Template:{templateName}". Access tokens, app
secrets, and rendered parameter values are never logged.
Configuration (WhatsAppCloudApi section): VerifyToken, AppSecret (environment-sourced),
SignatureValidationEnabled, LogRawPayload, AccessToken (environment-sourced), PhoneNumberId,
BusinessAccountId, GraphApiVersion, BaseUrl.
7.4 Push (Firebase)
| Purpose | Mobile/web push notifications via Firebase Cloud Messaging |
| Implementing class | FirebasePushNotificationService (Shumoul.Notification.Core/Notifications/), registered as a singleton |
| Interface | IPushNotificationService — deliberately does not extend ISingletonService/ITransientService to avoid double-registration with the platform's DynamicServiceRegistrationExtensions auto-scan; it is registered explicitly |
| Provider failover | Layer-1: INotificationProviderResolver.GetPushProviders() returns priority-ordered, enabled push providers (e.g. FirebasePushProvider); IProviderHealthMonitor skips any provider currently Unavailable |
| Dispatch-layer wrapper | PushChannelProvider |
| Methods | SendToTokenAsync(token, title, body, data) — delegates to SendToMultipleAsync with a single-token array. SendToTopicAsync(topic, title, body, data) — goes directly to Firebase (no multi-provider concept for topic sends; guarded by PushSettings.EnableSending and a FirebaseApp.DefaultInstance null-check). SendToMultipleAsync(tokens, PushNotificationRequest, ct) — iterates enabled/healthy providers in priority order, fails over on error, records success/failure against the health monitor. |
| Configuration | PushSettings section: ProjectId, CredentialJson (environment-sourced service-account JSON), CredentialFilePath, EnableSending (defaults false until explicitly turned on) |
| Recipient resolution | IDeviceTokenService.GetActiveTokensForUserAsync(userId) — see Chapter 11 — Device Tokens |
If all providers are exhausted (none healthy, or all attempts fail), SendToMultipleAsync returns false
and logs "[Push] All providers exhausted" — the caller (dispatch engine) treats this as a channel failure
subject to the same retry/dead-letter policy as any other channel.
7.5 SignalR
Real-time delivery to connected web clients. Fully documented in Chapter 10 — SignalR.
Summary: SignalRChannelProvider sends via the legacy NotificationHub (hosted in MultiTenancyApi) using
Clients.User(userId).SendAsync("messages", payload). Delivery is only received live if the user has an
active WebSocket connection — there is no queued redelivery at the SignalR layer itself (durability for
offline users comes from the InApp channel's AppNotification row, read via GetInbox on next login).
7.6 In-App
| Purpose | Durable, queryable notification record shown in the ERP's in-app notification center regardless of whether the user was online at send time |
| Implementing class | InAppChannelProvider (dispatch layer) → creates an AppNotification row |
| Entity | AppNotification (RecipientUserId, Title/FTitle, Body/FBody, Channel, IsRead, ReadOn, SentOn, EventKey, RefType, RefId) |
| Read via | AppNotificationController.GetInbox / GetUnreadCount / MarkAsRead / MarkAllAsRead — see §9.1 |
InApp and SignalR are frequently enabled together for the same event: SignalR gives an instant toast/badge update to an online user, while InApp guarantees the notification is still visible in the inbox if the user was offline or dismissed the toast.