13. Notification History
There are two, unrelated notions of "notification history" in this platform. Confusing them is the most common integration mistake — this chapter exists specifically to keep them separate.
13.1 The two history stores
NotificationDeliveryAttempt (current) | TenantNotificationLog (legacy) | |
|---|---|---|
| Owned by | Notification Framework (Shumoul.Notification.Persistence) | ERP host, SharedDbContext, table [Saas].[TenantNotificationLogs] |
| Written by | Every channel dispatch, for every channel | Only 2 narrow legacy call sites (see §13.2) |
| Purpose | Full delivery audit trail feeding retry/dead-letter/analytics | Dedup guard + rate-limit guard only |
| Exposed via | Notification Analytics, Delivery Receipts | NotificationHistoryController (legacy, blocked cleanup) |
| Growth | Append-only, unbounded (no cleanup worker exists today — see §12.6) | Small, narrow write volume |
13.2 What TenantNotificationLog is actually still for
Per the platform's own retention decision (docs/NOTIFICATION_PHASE3_TENANTNOTIFICATIONLOG_DECISION.md,
decided 2026-06-29, schema change explicitly forbidden without a separate approved migration proposal),
TenantNotificationLog is retained for exactly two purposes:
- Expiry-reminder dedup guard —
SendAlertNotificationThroughEmailHandler/SendAlertNotificationThroughSmsHandlercallReminderAlreadySentAsync()before dispatching, to avoid sending the same subscription-expiry reminder twice. - Resend rate-limit guard —
ResendVerificationServicecallsGetVerificationResendCountAsync()and writes records keyed oneventName = "VerificationResend"to cap how often a verification email/SMS can be resent.
It plays no role in the retry engine, the dispatch pipeline, or delivery analytics for
framework-dispatched notifications — those are entirely served by NotificationDeliveryAttempt and the
tables covered in Chapter 12. Several of its columns
(ExternalMessageId, SentAt, Hangfire retry fields NextRetryAt/RetryCount) are deprecated with no
active writer but remain in the schema — do not build new features that read them expecting live data.
13.3 Lifecycle summary
NotificationDeliveryAttempt (current):
- Created once per dispatch attempt (initial or retry), never updated or deleted.
- Read: unread/read state doesn't apply here — that's an
AppNotification(InApp) concept, see below. - Archive/cleanup: no automatic cleanup exists — flagged as an operational gap in §12.6.
AppNotification (InApp channel's durable record, not the same as delivery-attempt history):
- Unread → read:
MarkAsRead/MarkAllAsRead(§9.1). - Archive: soft-delete via
Deleteon the same controller, scoped to the recipient's own inbox. - Cleanup: no separate retention job — soft-deleted rows persist until an operator or future job purges them.
TenantNotificationLog (legacy):
- Read via
NotificationHistoryController.GetDataTable/GetDetails/GetCounters— legacy, blocked cleanup (see §9.12). - No archive/read-state concept — it is a plain audit log for dedup/rate-limit purposes only.
13.4 Migration status
Legacy Cleanup (retiring ITenantNotificationLogService/INotificationJobService entirely) remains
blocked as of this writing — NotificationHistoryController is the last active HTTP-facing caller of
INotificationJobService, and 9 other callers of ITenantNotificationLogService still exist elsewhere in
the ERP (primarily the two dedup/rate-limit call sites above, plus the WhatsApp sender's audit logging —
see §7.3). See docs/ARCHITECTURE/LEGACY_CLEANUP_READINESS.md
for the full gate-condition list. Do not remove either legacy service without re-confirming that readiness
document is current.