Documentation Index
Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt
Use this file to discover all available pages before exploring further.
What the notification system does
In-app notifications appear in the bell icon in the top navigation bar. The unread count badge updates via polling every 30 seconds. Clicking the bell opens a panel listing recent notifications, each with a timestamp and optionally a link to the relevant page.
Notifications are created automatically when relevant backend events occur — you do not call a notification API from the frontend. The backend creates notifications as part of command handlers. Users see them the next time their browser polls.
All notifications are tenant-scoped. A user receives notifications from their currently active tenant only. If they switch tenants, they see that tenant’s notifications.
How notifications are created
INotificationService provides two methods:
// Create a notification for a single user within the current tenant
await _notificationService.CreateAsync(
userId: targetUserId,
tenantId: tenantId,
type: NotificationType.MemberInvited,
title: "Invitation sent",
message: $"You invited {invitedEmail} to join {tenantName}.",
actionUrl: "/settings/members"
);
// Create a notification for all members of the current tenant
await _notificationService.CreateForAllTenantMembersAsync(
tenantId: tenantId,
type: NotificationType.MemberJoined,
title: "New member joined",
message: $"{newMemberName} joined {tenantName}.",
actionUrl: "/settings/members"
);
Both methods automatically invalidate the Redis unread count cache for the affected users.
Notification types
| Type | Title | When created | Who receives it | Action URL |
|---|
MemberInvited | Invitation sent | InviteMemberCommand succeeds | Tenant owner only | /settings/members |
MemberJoined | New member joined | AcceptInvitationCommand succeeds | All tenant members | /settings/members |
MemberRemoved | Member removed | RemoveMemberCommand succeeds | The removed user | None |
OwnershipTransferred | Ownership transferred | TransferOwnershipCommand succeeds | New owner and former owner | None |
SubscriptionUpgraded | Subscription activated | checkout.session.completed webhook | All tenant members | None |
SubscriptionCanceled | Subscription canceling | customer.subscription.updated webhook (cancel_at_period_end=true) | Tenant owner only | None |
SubscriptionPastDue | Payment failed | invoice.payment_failed webhook | Tenant owner only | /billing |
SubscriptionTrialExpiring | Trial ending soon | TrialExpiryJob (7-day and 1-day warnings) | Tenant owner only | /billing |
PaymentRecovered | Payment successful | invoice.payment_succeeded webhook (was PastDue) | Tenant owner only | None |
Unread count optimization
The unread count endpoint (GET /api/v1/notifications/unread-count) is called by the frontend every 30 seconds. Without caching, this would be a database query on every poll for every logged-in user.
Instead, the count is cached in Redis per user-tenant combination:
Cache key: "notifications:unread:{userId}:{tenantId}"
TTL: 30 seconds
When a new notification is created or a notification is marked as read, the cache for the affected user is invalidated immediately. The next poll hits the database and re-caches the count.
This means the unread count is at most 30 seconds stale in the worst case (if a notification arrived just after a poll), but typically updates within one poll cycle after the cache is invalidated.
How to add a new notification type
// 1. Add to the NotificationType enum in Domain
public enum NotificationType
{
// existing values...
MemberInvited,
MemberJoined,
// add your new type:
ProjectCreated,
}
// 2. Call INotificationService in the relevant command handler
public class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, Result<ProjectDto>>
{
private readonly IApplicationDbContext _context;
private readonly INotificationService _notificationService;
public async Task<Result<ProjectDto>> Handle(
CreateProjectCommand request,
CancellationToken cancellationToken)
{
var project = Project.Create(request.Name, request.Description, request.TenantId);
_context.Projects.Add(project);
await _context.SaveChangesAsync(cancellationToken);
// Notify all tenant members about the new project
await _notificationService.CreateForAllTenantMembersAsync(
tenantId: request.TenantId,
type: NotificationType.ProjectCreated,
title: "New project created",
message: $"Project \"{project.Name}\" was created.",
actionUrl: $"/projects/{project.Id}"
);
return Result.Success(_mapper.Map<ProjectDto>(project));
}
}
Notification security
Global query filters on the Notification entity ensure users cannot read other tenants’ notifications:
builder.Entity<Notification>()
.HasQueryFilter(n =>
n.TenantId == _tenantContext.CurrentTenantId &&
n.UserId == _currentUserService.UserId);
Both conditions must match. A user cannot read another user’s notifications within the same tenant, and cannot read any notifications from a different tenant.