> ## 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.

# Notification System

> In-app notifications with Redis-cached unread counts, automatic creation from backend events, and full tenant isolation.

## 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:

```csharp theme={null}
// 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

```csharp theme={null}
// 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:

```csharp theme={null}
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.
