Skip to content
bitzorcas
中EN

Concept

Notifications and templates

A source-verified manual for personal inboxes, notification state, preferences, CAP fan-out, external channels, versioned templates, rendering, security, and archival reads.

Last updated

Notifications contains two capability sets that are not yet a closed loop. One is the SysNotification personal inbox and external-channel fan-out. The other is the multi-table UniversalTemplate model with versions, variables, rendering, and channel content adapters. They share a module, but CreateNotification does not invoke template rendering: it accepts Title and Body directly.

1. Boundary the module actually owns

Implemented today:

  • a current-user inbox, unread count, and Read/Unread/Archived state;
  • user-level global or notification-code preferences;
  • the notification.created CAP event and severity-based default channels;
  • delivery ports for SMS, enterprise mail, WeCom, DingTalk, and Feishu;
  • tenant templates, version history, variable declarations, and Scriban compilation cache;
  • content adapters for Email, IM, SMS, Inbox, and HTML;
  • merged SQL Server reads across hot and archived notification tables.

Not implemented today: template-driven notification creation, a stable cross-module intent contract, batch recipients, scheduling, persisted delivery attempts, retries/compensation, deduplication, mandatory-notice rules, push/webhook delivery, attachments, bounce feedback, delivery/open receipts, or a Notifications feature catalog.

2. Current end-to-end shape

trueeither valueCaller must render andpass Title/Body

Current-user HTTP or internal NotificationService

Notification aggregate

InboxEnabled decision

SysNotification hot table

CAP notification.created

NotificationCreatedConsumer

Reload by Id

Severity + preference channel selection

SMS / Mail / WeCom / DingTalk / Feishu

Separate template APIs
not connected to CreateNotification

NotificationService.CreateAsync resolves preference first. It persists the aggregate only when InboxEnabled=true and always publishes an event afterward. The consumer reads only notificationId and ignores tenantId, userId, code, and skipExternalDelivery, so the payload and consumption semantics are asymmetric.

3. Two HTTP surfaces

Personal notifications

Method and routeUse caseAuthorization actionCurrent semantics
POST /api/notificationsCreateNotificationCreatecreate for the current UserId; client supplies Title/Body
GET /api/notificationsGetNotificationInboxViewstatus filter, 1..100 page size, archives included by default
GET /api/notifications/unread-countGetUnreadCountViewcurrent tenant/current user
POST .../{id}/readMarkNotificationReadUpdateidempotent; Archived stays Archived
POST .../{id}/unreadMarkNotificationUnreadUpdateArchived returns Conflict
POST .../{id}/archiveArchiveNotificationUpdatechanges status; does not move the physical row
POST .../read-allMarkAllNotificationsReadUpdateloads all unread rows and SaveRange
GET/PUT .../preferencesGet/UpdatePreferenceView/Updateexact Code or global empty Code

The permission catalog declares only notifications.notification.view and .update, while CreateNotification requests AuthorizationAction.Create. No .create definition exists, which is governance drift that must be fixed and protected by a real authorization contract test.

Templates

Templates expose 11 generated endpoints: Create, Update, Delete, Get, Search, Versions, Activate, Render, Preview, and Validate. Activate uses the generic Update action, while Preview/Validate/Render use View. The separately declared .activate and .preview permission codes are not referenced by those requests.

4. Core storage models

SysNotification
unified hot aggregate

SysNotification_Archive
read-only history

SysNotificationPreference
user + Code

SysUniversalTemplate
template root

SysUniversalTemplateVersion
retained version history

SysTemplateVariable
current variable set

Notification is a unified TenantAggregateRoot. Templates are a registered multi-table asymmetric exception; an explicit projector translates root, version, and variable records. Child-table operations carry TenantId+TemplateId. Root lookup by TemplateId relies on the global tenant filter instead of an explicit tenant argument.

The default SQL Server retention policy transactionally moves notifications older than 365 days into SysNotification_Archive; ArchiveDays is 1,095 and Action is ColdStorage. Inbox reads default IncludeArchived=true and merge windows from both tables. This is unrelated to the immediate ArchiveNotification status command.

5. Creating a notification today

Create an inbox notification for the current user
POST /api/notifications HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"code": "tickets.assignment.changed",
"title": "A ticket was assigned to you",
"body": "Ticket T-20260715-0042 was reassigned by first-line support.",
"type": "Todo",
"category": "Tickets",
"severity": "Warning",
"linkUrl": "/tickets/T-20260715-0042",
"metadataJson": "{\"email\":\"agent@example.com\",\"phone\":\"13800000000\"}"
}

The HTTP handler derives TenantId and UserId from CurrentUser; a client cannot choose another recipient. It writes "0" when UserId is absent. The aggregate validates only that TenantId, UserId, Code, and Title are non-empty. It does not pre-validate persistence lengths, MetadataJson syntax, or LinkUrl schemes.

Internal modules can call NotificationService with a target userId, but must align the ambient EffectiveTenant with the tenant argument and must define template selection, body generation, idempotency, and sensitive-data minimization themselves.

6. Most important current gaps

  1. CreateNotification is disconnected from templates and never resolves by Code or language.
  2. Notification Create is absent from the catalog; Template Activate/Preview permissions exist but are unused.
  3. HTTP handlers use User.TenantId and a UserId=0 fallback instead of one EffectiveTenant/user requirement.
  4. Creation has no business idempotency key; duplicate requests or events create duplicate rows.
  5. InboxEnabled=false prevents persistence and therefore breaks external consumption.
  6. The CAP consumer ignores tenantId and skipExternalDelivery and establishes no explicit tenant context.
  7. Consumer/channel failures are swallowed with no queryable state, retry, or reconciliation.
  8. EnabledChannels is stored but ignored, and no mandatory-notice rule prevents disabling everything.
  9. Rendering uses an empty TenantId, does not authorize CallerModule, and treats every engine as Scriban.
  10. Bilingual Identity seeds share Tenant+TemplateKey, so the second locale is skipped; Identity still uses inline fallback bodies.
  11. Update ignores Description; import ignores historical versions and variables from its own export JSON.
  12. HTML, Email, and Inbox body handling is not a production-grade allowlist-sanitization boundary.

7. Reading path

  1. Inbox, state machine, and archived reads
  2. Preferences, CAP, and multi-channel delivery
  3. Templates, versions, variables, and multi-table persistence
  4. Rendering, channel adaptation, and content safety
  5. Testing, operations, and GA gates

Related chapters: Authorization, Multitenancy, Auditing, and Operations.

8. Source checks

Terminal window
# Every generated notification/template route and authorization action.
rg -n 'GenerateEndpoint|AuthorizationAction' \
src/Platform/Notifications -g '*.cs'
# The real create, publish, consume, and exception path.
rg -n 'notification.created|skipExternalDelivery|FindByIdAsync|catch \(Exception' \
src/Platform/Notifications -g '*.cs'
# Template tenant, engine, module-caller, and version-number boundaries.
rg -n 'GetByKeyAsync\(|CallerModule|TemplateEngine|OrderByDescending' \
src/Platform/Notifications -g '*.cs'

Back to module catalog

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%