Skip to content
bitzorcas
中EN

Guide

Notifications inbox, state machine, and archived reads

A detailed account of the unified Notification aggregate, personal ownership, Unread/Read/Archived transitions, paging, mark-all-read, hot/archive merging, and tenant boundaries.

Last updated

The personal inbox uses the unified Notification aggregate as its write-side fact. The read side projects the hot table through QueryShape and can merge a read-only archive table. HTTP operations are restricted to the current UserId; only the internal NotificationService accepts an explicit recipient.

1. Aggregate fields and input boundaries

FieldPersistence limitCurrent validation
Id/NotificationIdaggregate keyrepository replaces placeholder "0"
UserId64non-empty only; HTTP writes "0" without a user
Code100non-empty only
Title255non-empty; no length pre-check
Bodymax textnull becomes empty string
Category50blank becomes General
LinkUrl512no scheme/host validation
LinkText100no length pre-check
MetadataJsonmax JSON columnnot parsed; delivery converts invalid JSON to empty contact data

Type, Severity, and Status persist SmartEnum names. Unknown stored values fail closed during restoration. New rows default to Business, Info, and Unread.

Create a notification for a target user from an internal module
var result = await notificationService.CreateAsync(
tenantId: tenant.Id,
userId: assignee.UserId,
code: "tickets.assignment.changed",
title: "A ticket was assigned to you",
body: $"Ticket {ticket.Number} must be answered before its SLA deadline.",
type: NotificationType.Todo,
category: "Tickets",
severity: NotificationSeverity.Warning,
linkUrl: $"/tickets/{ticket.Id}",
// Include only contact data needed by delivery; never serialize User/Ticket graphs.
metadataJson: JsonSerializer.Serialize(new { email = assignee.Email }),
cancellationToken);
// There is no DeduplicationKey today; replay creates another notification.
if (result.IsFailure)
return Result.Failure(result.Error);

2. State transitions

CreateMarkReadMarkReadMarkUnreadMarkUnreadArchiveArchiveArchive / MarkReadMarkUnread

Unread

Read

Archived

Conflict

MarkRead and Archive are idempotent. MarkRead on Archived succeeds and preserves Archived; if ReadAt was null, the aggregate stores this readAt. MarkUnread clears ReadAt but returns Notification.AlreadyArchived for Archived.

ArchiveNotification only changes Status in the hot row. It does not move data into SysNotification_Archive. The generic retention executor performs age-based physical archival, so these two meanings of “archive” must not be conflated.

3. Current-user ownership

Read, Unread, and Archive load the aggregate by Id, then NotificationService compares both TenantId and UserId. Any mismatch returns NotFound to avoid existence disclosure.

Ownership-denial contract
[Fact]
public async Task MarkRead_Should_Not_Reveal_Another_Users_Notification()
{
// user-a owns the row; the request principal is user-b in the same tenant.
var notification = Notification.Create(
"tenant-a", "user-a", "invoice.ready", "Invoice ready", "Your service invoice has been generated. View it in billing.").Value!;
repository.Seed(notification);
// The service restores the aggregate before checking TenantId + UserId ownership.
var result = await service.MarkReadAsync(
"tenant-a", "user-b", notification.Id, CancellationToken.None);
// The response is a uniform NotFound and the state remains unchanged.
result.Error.Code.ShouldBe(NotificationErrorCodes.NotFound);
notification.Status.ShouldBe(NotificationStatus.Unread);
}

FindByIdAsync itself accepts neither tenant nor user and relies on the generic repository’s ambient tenant filter. HTTP ownership uses CurrentUser.User.TenantId. During impersonation, UserTenant and EffectiveTenant can differ, causing the repository to see a target-tenant row while ownership rejects with the home tenant—or to miss it earlier. Use one EffectiveTenant snapshot throughout.

4. Inbox paging

GET accepts Status, PageIndex, PageSize, and IncludeArchived. The handler maps an invalid page index to 1 and PageSize <= 0 or PageSize > 100 to 20. The store clamps PageIndex to 1..1000 and PageSize to 1..100.

The hot-table query pushes paging down and orders by CreateTime descending, then NotificationId descending. Its predicate always includes TenantId+UserId and optionally Status.

Read unread notifications including archive history
GET /api/notifications?status=Unread&pageIndex=1&pageSize=20&includeArchived=true HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Accept: application/json

With IncludeArchived=true, the read store fetches pageIndex * pageSize rows from each table, merges, orders, skips, and takes in memory. Page 1000 at size 100 can read 100,000 rows per side. This is bounded but still deep-page amplification; a production API should use keyset/cursor paging.

5. Unread count and mark-all-read

GetUnreadCount counts UnreadNotificationFilter(tenantId,userId) in the hot repository only. Archived Unread rows do not contribute, so the badge and an IncludeArchived inbox can disagree if retention moves unread data.

MarkAllReadAsync:

  1. loads every unread aggregate for the tenant/user;
  2. invokes MarkRead on each row;
  3. saves the complete collection with SaveRange;
  4. returns the count.

There is no page or batch cap. A high-backlog user can amplify memory, transaction duration, and update volume. Prefer one scoped set update or bounded batches with a defined ReadAt.

6. Hot and archive tables

The SQL Server NotificationInbox policy has OnlineDays=365, ArchiveDays=1095, Action=ColdStorage, and ArchiveTable=SysNotification_Archive. The executor uses DELETE ... OUTPUT ... INTO in per-tenant transactions and 1,000-row loops.

The generic DataRetention job currently auto-processes only Action=Delete policies. A ColdStorage policy therefore requires the archive-operations path to trigger it; registry presence alone is not proof of scheduled movement.

The archive row is a read-only asymmetric model. Notification commands search the hot repository only. A physically archived item can appear in GET but cannot be changed through the current Read/Unread/Archive commands.

7. Data and rendering safety

Inbox responses expose the complete Body, MetadataJson, and LinkUrl. A UI must:

  • treat Body as text or sanitized controlled Markdown;
  • allow only relative application links or approved hosts, rejecting javascript/data schemes;
  • avoid expanding MetadataJson into DOM, analytics, or error logs;
  • apply identical authorization and output encoding to archived rows;
  • assign retention and GDPR handling to notification PII.

There is no user-facing soft-delete/clear-inbox command or GDPR contributor. Physical archival retains body and metadata; moving a row out of the hot table is not data minimization.

8. Errors and client behavior

ConditionResultClient behavior
required creation metadata absentNotification.MetadataRequiredcorrect input
id absent, other user, or tenant mismatchNotification.NotFoundhide action; do not probe
mark Archived as UnreadNotification.AlreadyArchivedpreserve archived state
repeated Read/ArchiveSuccesstreat as idempotent
deep pageclamped/window amplifiedavoid deep navigation
mutate physically archived rowNotFoundcurrently read-only

9. Required tests

  • persistence-length boundaries, invalid MetadataJson, and unsafe LinkUrl;
  • Unread→Read→Unread, Unread/Read→Archived, and every replay;
  • exact MarkRead behavior for Archived with null ReadAt;
  • same-tenant other user, cross-tenant, and UserTenant≠EffectiveTenant;
  • page boundaries, equal-CreateTime order, and duplicate Id across hot/archive;
  • badge/list policy when archive contains Unread;
  • MarkAllRead capacity and transaction behavior for 100,000 rows;
  • equivalent SqlSugar/EF hot queries and SQL Server archive/restore behavior.

Back to Notifications

100%

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