Raise an in-app notification from the API and make it render in the web app — INotificationService, user / tenant-wide / platform-wide addressing and the tenant scope each needs, the translation-key contract for title/message, groups and metadata, the SignalR hub each committed notification is pushed through (its groups, publishing after commit, NewUserNotificationsAsync), and the badge. Use when a feature needs to tell users something happened.
Instal·la
npx skillscat add ma496/easy-for-net/notifications Instal·la des del registre de SkillsCat.
In-app notifications
Raising one
Inject INotificationService (from Backend.Features.Notifications.Core) and call the method for
the audience:
// one account, in the active scope - a tenant, or platform scope
await notificationService.NewUserNotificationAsync(
userId,
NotificationType.Warning,
titleKey: "notifications.inventoryBelowLimit.title",
messageKey: "notifications.inventoryBelowLimit.message",
group: "inventory",
metadata: "{\"itemName\":\"Widget A\",\"currentQty\":5}",
cancellationToken);
// every member of the active tenant
await notificationService.NewTenantNotificationAsync(NotificationType.Info,
"notifications.planChanged.title", "notifications.planChanged.message", "system", cancellationToken: cancellationToken);
// every user of the platform, whichever tenant they act in
await notificationService.NewGlobalNotificationAsync(NotificationType.Info,
"notifications.welcome.title", "notifications.welcome.message", cancellationToken: cancellationToken);NotificationType is Info | Warning | Error | Success and drives the icon/colour in the UI.
To tell many accounts the same thing, call NewUserNotificationsAsync(userIds, ...) rather than looping
over NewUserNotificationAsync: one personal row per distinct recipient in the active scope, inserted in
batches of at most 1,000 inside one transaction (the caller's, when one is open), and one push per
recipient.
await notificationService.NewUserNotificationsAsync(
approverIds, NotificationType.Info,
"notifications.approvalRequested.title", "notifications.approvalRequested.message",
group: "approvals", cancellationToken: cancellationToken);Scope. Attribution comes from ITenantContext, never from an argument, and every method butNewGlobal… throws TenantScopeNotEstablishedException with no scope at all. NewUser… stamps the active scope: inside
a tenant the recipient sees the row only while acting in that tenant, and in platform scope the row
names no tenant and the recipient sees it only while acting in platform scope - this is how a platform
account is told something personally. NewTenant… needs an actual tenant and throwsInvalidOperationException in platform scope, where its row would read as the platform-wide broadcast.NewGlobal… opens platform scope itself and writes TenantId = null. From a request the scope is
already set. To address the account in a tenant other than the one the request acts in (a platform
administrator adding a member from platform scope, say) or from a background job, open it first withusing (tenantContext.BeginTenant(tenantId)) { ... } (see background-jobs, and multi-tenancy for
the model), after saving any other pending work, because attribution is stamped at save time.
Cross-feature use: INotificationService and NotificationType are [AllowOutside], so another
slice injects the service directly (TenantMemberAddEndpoint tells an account it joined a
tenant). The entities stay private to the slice - never reach for dbContext.Notifications from
outside it. See the backend-feature skill.
The translation-key contract
TitleKey and MessageKey are i18n keys, not text — NotificationItem renders them witht(notification.titleKey). So raising a notification always means adding the matching keys tosrc/backend/Source/Features/Localization/Core/Resources/en.json (and every other shipped resource
file there):
"notifications": {
"inventoryBelowLimit": { "title": "Low Inventory Alert", "message": "Item is below the minimum stock level" }
}Never pass user-facing English through titleKey/messageKey, and never interpolate values into
the key — variable parts go in Metadata. The web app interpolates the top-level string and number
values of a JSON-object Metadata into the text (notificationVariables in lib/utils), so a
message "You have been added to ${tenantName}" raised withJsonSerializer.Serialize(new { tenantName = tenant.Name }) shows the name.
Group is a short lowercase slug used to filter the list; add its label undernotifications.groups.<slug> (the UI falls back to the raw slug). Metadata is an opaque JSON
string for details the UI may show; keep it small and stable.
Addressing and read state
| Row | TenantId |
UserId |
Read state |
|---|---|---|---|
| user, in a tenant | active tenant | recipient | IsRead on the row |
| user, in platform scope | null | recipient | IsRead on the row |
| tenant-wide | active tenant | null | the user's visit row, else their read cursor for that tenant |
| platform-wide | null | null | the user's visit row, else their platform-wide read cursor |
One audience row is shared by all its readers, so its read state is the reader's, kept in two places:
NotificationReadCursor(UserId,TenantId,ReadAllAt) — a user's "mark all as read" point for
one audience, unique on (UserId,TenantId) with nulls not distinct: one per tenant, and one
platform-wide cursor (TenantIdnull). An audience row is read when the cursor for its audience was set
at or after itsCreatedAt. Mark-all-as-read moves the cursors; it never writes a row per notification.NotificationVisit(NotificationId,UserId,IsRead), unique on (NotificationId,UserId) —
an explicit per-notification state that overrides the cursor either way:IsRead = truefor one
marked read above the cursor,IsRead = falsefor one marked unread below it. Mark-as-read and
mark-as-unread upsert it (INSERT ... ON CONFLICT); mark-all-as-read deletes the visits its cursors
cover.
Both belong to the user rather than the scope acted in, so a platform-wide row marked read inside one
tenant reads as read everywhere.
What a caller sees is "rows of the active scope addressed to them or to nobody, plus the platform-wide
rows" — so a personal row never crosses between a tenant and platform scope, and only the platform-wide
broadcast is seen everywhere. That is the internal VisibleTo(userId, activeTenantId) inCore/NotificationQueries.cs, applied over .AcrossAllTenants() because the tenant filter alone would
hide the platform-wide rows from a caller acting in a tenant. Whether a visible row is read is decided in
one place too: WithReadState(dbContext, userId) beside it pairs each notification with IsRead
(NotificationWithReadState), and the unread count, the list's isRead filter, its default ordering
(unread first, then newest) and its projection, and GET {id} all filter, order and project on that.IsRead. Never restate the rule in a query of your own — chain .VisibleTo(...).WithReadState(...).
Writes go through SetAudienceReadStateAsync for one audience row, and the hand-written statements in
mark-all-as-read spell out their tenant predicate, since no query filter reaches them.
The unread count is capped. NotificationQueries.CountUnreadAsync(dbContext, userId, activeTenantId, ct)
counts at most UnreadCountCap (100) rows in a single statement — a count over a LIMITed subquery,
with no identifiers read into memory — and takes the user and scope as arguments, so it can be computed
for any user in any scope. INotificationService.GetUnreadCountAsync calls it for the active scope; the
web badge shows "99+" above 99.
Notifications are AuditableEntity<Guid> + ISoftDelete + IMayHaveTenant; deleting soft-deletes
and the global query filter hides the row. The delete endpoint removes only the caller's own
user-targeted rows — an audience row cannot be deleted by one reader.
Retention
Notifications:RetentionDays (NotificationOptions, default 90, must be greater than zero, checked byValidateOnStart) bounds how long a notification lives. The daily delete-expired-notifications job
(INotificationRetentionService.DeleteExpiredAsync, registered in Program.cs) hard-deletes every
notification whose CreatedAt is older than that - read or unread, soft-deleted too - in batches of at most
5,000 rows per statement, and its visit rows go with it through the cascading foreign key. It then prunes theIsRead = true visits on audience notifications that the visiting user's cursor for that audience already
covers (CreatedAt <= ReadAllAt); an IsRead = false visit, or a read one above the cursor, carries
information and is kept. The job establishes no tenant scope: its hand-written SQL has no tenant predicate
and spans every tenant and platform scope by design, and deletes nothing else. A test of it asserts only on
rows it created, dates "old" rows far past any retention period, and stays in the Notifications collection.
Pushing to connections
NotificationHub (Core/Push) is mapped at /hubs/notifications, outside the API route prefix, behind
the same Jwt_Or_Cookie authentication and session validation as every endpoint: the upgrade request is
refused with 401 exactly when an HTTP request with the same credential would be, and with 503sessionStoreUnavailable when the session store is down. A browser authenticates with the auth cookie; a
bearer client passes its token as ?access_token=, which is read on the hub's path alone
(NotificationHubRegistration.CarriesQueryAccessToken) and kept out of the request log. An upgrade
whose Origin is not one of the Web domains (WebSetting.AllowedDomains(), the list the CORS policy
allows) is refused with 403 by the WebSocket middleware (WebSocketOptions.AllowedOrigins), so another
host on the same site cannot open the hub with a visitor's cookie; one with no Origin - a non-browser
client - is let through. It is WebSockets only (long polling and server-sent events are refused; clients skip negotiation),
server-to-client only (no client-callable method), and closes when its credential expires.
On connect the hub joins the connection to groups built from the projected session alone - the client
never names one (NotificationGroups). SignalR answers the handshake before those joins run, so a
client's start completing does not mean it is in its groups; the account's own u: group is joined
last, so a message arriving on it proves the others are joined too:
| Group | Joined by | Receives |
|---|---|---|
u:{userId}:{tenantId|platform} |
every connection, for its account in its scope | personal rows raised in that scope; the caller's unreadCountChanged |
t:{tenantId} |
connections acting in a tenant | that tenant's tenant-wide rows |
all |
every connection | platform-wide rows |
These are the audiences VisibleTo reads, so a connection receives exactly the rows its session would
list. INotificationService publishes notificationReceived - the list row's fields (id, type,titleKey, messageKey, group, metadata, createdAt, isRead: false) - to the one group of the
audience, one message per notification whatever the audience's size. Mark-as-read, mark-as-unread,
mark-all-as-read and delete publish unreadCountChanged ({ count }, the cappedNotificationQueries.CountUnreadAsync) to the caller's own u: group only; nothing is recomputed per
member of an audience.
Publishing happens after commit. INotificationPublisher (slice-private) sends at once when theAppDbContext has no open transaction, and otherwise hands the send to NotificationCommitInterceptor,
a DbTransactionInterceptor that runs it when that transaction commits and drops it on rollback, failure,
or disposal without a commit. So raise inside the caller's transaction freely - nothing reaches a
connection for a row that does not exist. Not covered: a System.Transactions ambient scope (sent straight
after the save) and a savepoint rollback inside a transaction that then commits. A push that fails is
logged and never fails the raise or the commit. A Hangfire job raises exactly as a request does; the push
goes through the Redis backplane (ConnectionStrings:Redis, channels prefixed with Redis:InstanceName)
from whichever process runs it. Under Testing there is no backplane - pushes stay in process.
Connection cap. Notifications:MaxConnectionsPerUser (NotificationOptions, default 20, validated on
start) caps one account's concurrent connections per API instance; the connection over it is closed
with connectionLimitExceeded before joining any group, and logged. NotificationConnectionRegistry (a
per-instance singleton) tracks open connections by connection, account and session (OfSession(sid)).
Hub options: keep-alive and client timeout at SignalR's defaults, a 4 KB MaximumReceiveMessageSize
(clients send only the handshake and pings).
Connection lifecycle. A connection lives no longer than the session that authenticated it. Identity
announces every ended session id - revocation, sign-out, the session replaced by refresh or tenant
switch/exit - to each [AllowOutside] ISessionEndedHandler, on every API instance (Redis pub/sub outsideTesting, in process under it). NotificationSessionEndedHandler aborts each connectionregistry.OfSession(sid) returns here; the client sees the connection close and reconnects with its
renewed credential, if it has one. A failed announcement is logged and never fails the change that ended
the session. Identity never references this slice. The connection also closes when its access token
expires (CloseOnAuthenticationExpiration).
Endpoints
Under Features/Notifications/Endpoints/Notifications with the notifications prefix: list
(GET "", filters isRead and group), GET {id}, DELETE {id}, POST {id}/mark-as-read,POST {id}/mark-as-unread, POST mark-all-as-read, GET unread-count, GET groups. There is no
create endpoint — notifications are raised by server code. They are authenticated but carry no
permission — every signed-in user sees their own notifications. Follow that when adding one. An
ordinary account whose tenant was dropped at token renewal acts in platform scope and is answered
with the platform-wide broadcasts alone; that is harmless, and the web app sends it to/select-tenant rather than connecting to the hub.
Web side
notificationsApi(store/api/notifications/notifications) uses theNotificationstag type;
mutations invalidate the collection plus the touched row.notificationGetUnreadCountprovides{ type: 'Notifications', id: 'UNREAD_COUNT' }, so every mutation refetches the badge at once;
a notification mutation must keep invalidating theNotificationstype, or while the hub is down the
badge waits for the fallback poll. Every list (and the group list) also providesNOTIFICATIONS_LIST_TAG({ type: 'Notifications', id: 'LIST' }), which a push invalidates without
touching the unread count.useNotificationHub()(hooks/) is mounted once, incomponents/layouts/header.tsx- do not mount it
per screen. It holds one SignalR connection (@microsoft/signalr) to the hub on the API host
(lib/notifications/hub-url.tsdrops the API route prefix), WebSockets only with negotiation skipped,
authenticated by the auth cookie - no token in the URL, so the web origin must be one of theWeb
domains and the cookie must reach the API host. It connects only while the caller has an active tenant
or is a platform account acting in none (anyone else is on/select-tenant), and stops on sign-out,
when that stops holding, and on unmount. Each host is one tenant with its own session, so a connection only ever serves one tenant; another tenant is another tab with its own connection.- Messages:
notificationReceiveddispatchesnotificationsSlice.notificationReceived(one more unread)
and invalidatesNOTIFICATIONS_LIST_TAG;unreadCountChangeddispatchessetUnreadCount(count). The
unread count is fetched once after every connect and reconnect, since pushes sent while it was down
are lost. - Reconnect:
withAutomaticReconnectwithreconnectDelayMs(lib/notifications/hub-reconnect.ts) -
exponential back-off with jitter, capped at 60 s, never giving up. A failed first start, or a close the
server does not let reconnect, is retried by the hook on the same schedule. - 401: a refused WebSocket upgrade shows the browser no status, so every failed attempt to connect (a
start, or one of automatic reconnect's attempts) is answered by one unread-count fetch through RTK Query.
It carries the same cookie, so it answers 401 exactly when the upgrade was refused for authentication,
andbaseQueryWithReauththen refreshes the session under its mutex - or signs out, which stops the
connection. A connection merely being lost is not probed, only a failed attempt. Sign-out closes the
connection server-side at once, so a probe then would find no session to refresh:leaveSignedOut
(store/tenant-cache.ts) marks the navigation to sign-in first (markLeavingSignedOutinstore/signed-out-navigation.ts), after which the hook probes
nothing and a failed refresh redirects nowhere. Never refresh from the hook by any other route. - Fallback polling: none while connected; while disconnected, the unread count every
fallbackPollDelayMs(60 s, give or take 10%), paused while the tab is hidden, with one fetch when it
becomes visible again. components/notifications/holdsNotificationBell(the badge, whose text isformatUnreadBadge(count): no badge at zero or below,99+above 99),NotificationPanel(dropdown
list) andNotificationItem(single row, renderst(titleKey, notificationVariables(metadata))and the
same formessageKey), with full pages underapp/[lang]/(app)/notifications/(the list at its
root, one notification under[id]).
Testing
Derive from NotificationsTestsBase (it carries [Collection("Notifications")], because
platform-wide rows reach every account) and use its CreateUserNotificationAsync,CreateTenantNotificationAsync, CreatePlatformUserNotificationAsync, CreateGlobalNotificationAsync
helpers. Assert an audience row's read state through the API — IsReadForAsync(client, id) andUnreadCountAsync(client) — because a row the read cursor covers has no visit row at all;IsVisitedAsync (a read visit row exists) and VisitCountAsync are for asserting what was stored.
Anything that depends on unread state belongs to a user and tenant the test creates. NotificationPlatformScopeTests
shows asking one platform account from both sides of the boundary: ClientForAsync(username) signs it in
to platform scope, ClientForAsync(username, tenantId) to its tenant.
Hub tests (Tests/Features/Notifications/Core/NotificationHubTests) open real connections withHubProbe.ConnectAsync(App.Server, accessToken, userId, tenantId) - WebSockets over the test server,
negotiation skipped, the token as access_token, returning only once a sentinel sent to the connection'su: group has arrived (so every group is joined) - and wait for a message withWaitForNotificationAsync(predicate) / WaitForUnreadCountAsync(). Show that a message did not arrive
with probe.DrainAsync(sender) first: it sends a sentinel to the connection's own group, and messages to
one connection arrive in order, so once the sentinel is in, an earlier push would be too - never sleep.
Sentinels are kept out of Notifications and WaitForNotificationAsync. A connection expected to be
refused is built with HubProbe.Create and started by the test, never through ConnectAsync. The
test server's WebSocket client bypasses the middleware's origin check, so that is tested by drivingWebSocketMiddleware directly. Always dispose probes (await using).
Checklist
- Called
NewUserNotificationAsync/NewTenantNotificationAsync/NewGlobalNotificationAsyncwith key strings, not text - The scope the recipient will read it in is active: their tenant, or platform scope for a platform account (jobs and cross-tenant callers open one explicitly)
-
notifications.<name>.titleand.messageadded to every backend resource file -
notifications.groups.<slug>added if a new group was introduced - Any new endpoint reads through
AcrossAllTenants().VisibleTo(...), and takes read state from.WithReadState(...)rather than restating the visit / cursor rule - An audience row's read state is written by upserting the visit row (
SetAudienceReadStateAsync), never by inserting one visit per notification in bulk - Unread counts go through
NotificationQueries.CountUnreadAsyncand stay capped atUnreadCountCap - Many recipients of the same notice go through
NewUserNotificationsAsync, not a loop - New notification rows are written through
INotificationService(which publishes), never added todbContext.Notificationsdirectly - An endpoint that changes the caller's read state calls
INotificationPublisher.PublishUnreadCountAsync(userId, activeTenantId, ...)after its change, and publishes to nobody else - A push is published through
INotificationPublisher(after commit, failures logged), never throughIHubContextdirectly - Hub groups come from the session alone; no client-callable hub method is added