Skip to main content

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

TypeTitleWhen createdWho receives itAction URL
MemberInvitedInvitation sentInviteMemberCommand succeedsTenant owner only/settings/members
MemberJoinedNew member joinedAcceptInvitationCommand succeedsAll tenant members/settings/members
MemberRemovedMember removedRemoveMemberCommand succeedsThe removed userNone
OwnershipTransferredOwnership transferredTransferOwnershipCommand succeedsNew owner and former ownerNone
SubscriptionUpgradedSubscription activatedcheckout.session.completed webhookAll tenant membersNone
SubscriptionCanceledSubscription cancelingcustomer.subscription.updated webhook (cancel_at_period_end=true)Tenant owner onlyNone
SubscriptionPastDuePayment failedinvoice.payment_failed webhookTenant owner only/billing
SubscriptionTrialExpiringTrial ending soonTrialExpiryJob (7-day and 1-day warnings)Tenant owner only/billing
PaymentRecoveredPayment successfulinvoice.payment_succeeded webhook (was PastDue)Tenant owner onlyNone

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.