Skip to main content
Version: 1.2

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 byNotification Framework (Shumoul.Notification.Persistence)ERP host, SharedDbContext, table [Saas].[TenantNotificationLogs]
Written byEvery channel dispatch, for every channelOnly 2 narrow legacy call sites (see §13.2)
PurposeFull delivery audit trail feeding retry/dead-letter/analyticsDedup guard + rate-limit guard only
Exposed viaNotification Analytics, Delivery ReceiptsNotificationHistoryController (legacy, blocked cleanup)
GrowthAppend-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:

  1. Expiry-reminder dedup guardSendAlertNotificationThroughEmailHandler / SendAlertNotificationThroughSmsHandler call ReminderAlreadySentAsync() before dispatching, to avoid sending the same subscription-expiry reminder twice.
  2. Resend rate-limit guardResendVerificationService calls GetVerificationResendCountAsync() and writes records keyed on eventName = "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 Delete on 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.