feat: implement notification system with repository, service, and controller layers
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationRepository
|
||||||
|
{
|
||||||
|
// ── Inbox ────────────────────────────────────────────────────────────
|
||||||
|
Task AddAsync(Models.Notification notification);
|
||||||
|
/// <summary>Stops tracking a notification (and its recipients) whose save failed.</summary>
|
||||||
|
void Discard(Models.Notification notification);
|
||||||
|
|
||||||
|
Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly);
|
||||||
|
Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize);
|
||||||
|
Task<int> GetUnreadCountAsync(Guid userId);
|
||||||
|
Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId);
|
||||||
|
Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId);
|
||||||
|
void RemoveRecipient(Models.NotificationRecipient recipient);
|
||||||
|
|
||||||
|
// ── Recipient resolution ─────────────────────────────────────────────
|
||||||
|
Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds);
|
||||||
|
Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode);
|
||||||
|
Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id);
|
||||||
|
Task<Guid?> GetReportsToUserIdAsync(Guid userId);
|
||||||
|
Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id);
|
||||||
|
|
||||||
|
// ── Retention ────────────────────────────────────────────────────────
|
||||||
|
Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take);
|
||||||
|
Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take);
|
||||||
|
void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients);
|
||||||
|
void RemoveNotifications(IEnumerable<Models.Notification> notifications);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Daily retention purge: deletes read inbox rows older than <c>Notifications:RetentionDays</c>
|
||||||
|
/// (default 90), then any notification left with no recipients.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationCleanupJob(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<NotificationCleanupJob> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int BatchSize = 500;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Let the app finish starting before the first run.
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await RunOnceAsync(stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { logger.LogError(ex, "Notification cleanup failed."); }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var retentionDays = config.GetValue("Notifications:RetentionDays", 90);
|
||||||
|
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||||
|
|
||||||
|
using var scope = scopes.CreateScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||||
|
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||||
|
|
||||||
|
var removed = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var expired = await repo.GetExpiredReadRecipientsAsync(cutoff, BatchSize);
|
||||||
|
if (expired.Count == 0) break;
|
||||||
|
|
||||||
|
repo.RemoveRecipients(expired);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
removed += expired.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var orphans = await repo.GetOrphanNotificationsAsync(BatchSize);
|
||||||
|
if (orphans.Count == 0) break;
|
||||||
|
|
||||||
|
repo.RemoveNotifications(orphans);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0)
|
||||||
|
logger.LogInformation("Notification cleanup removed {Count} read inbox rows older than {Days} days.", removed, retentionDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Every endpoint acts on the signed-in user's own notifications only.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/notifications")]
|
||||||
|
public class NotificationController(NotificationService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetInbox(
|
||||||
|
[FromQuery] bool unreadOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
return Ok(await service.GetInboxAsync(userId, unreadOnly, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<IActionResult> GetUnreadCount()
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
return Ok(await service.GetUnreadCountAsync(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/read")]
|
||||||
|
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.MarkReadAsync(userId, id, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("read-all")]
|
||||||
|
public async Task<IActionResult> MarkAllRead(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
await service.MarkAllReadAsync(userId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await service.DeleteAsync(userId, id, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <param name="Values">Object whose public properties fill the {Token} placeholders in the type's templates.</param>
|
||||||
|
/// <param name="CreatedByUserId">The acting user; excluded from the recipients.</param>
|
||||||
|
public record NotificationArgs(
|
||||||
|
object? Values,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
string Link,
|
||||||
|
Guid? CreatedByUserId = null);
|
||||||
|
|
||||||
|
/// <param name="Id">The caller's own recipient row id — use it for read / delete.</param>
|
||||||
|
public record NotificationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Type,
|
||||||
|
NotificationSeverity Severity,
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
string EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
string Link,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool IsRead,
|
||||||
|
DateTime? ReadAt);
|
||||||
|
|
||||||
|
public record UnreadCountResponse(int Count);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server → client only. The server pushes a "notification" event to each recipient; clients never
|
||||||
|
/// invoke hub methods. Users are addressed by their id (see <see cref="NotificationUserIdProvider"/>).
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
public class NotificationHub : Hub
|
||||||
|
{
|
||||||
|
public const string Path = "/hubs/notifications";
|
||||||
|
public const string ClientEvent = "notification";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a SignalR connection to the "UserId" claim that <c>AuthMiddleware</c> puts on the principal.</summary>
|
||||||
|
public class NotificationUserIdProvider : IUserIdProvider
|
||||||
|
{
|
||||||
|
public string? GetUserId(HubConnectionContext connection) =>
|
||||||
|
connection.User?.FindFirst("UserId")?.Value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public class NotificationRecipientResolver(INotificationRepository repo)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Expands the rules into a de-duplicated list of active user ids, never including
|
||||||
|
/// <paramref name="actingUserId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<Guid>> ResolveAsync(IEnumerable<RecipientRule> rules, Guid? actingUserId)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<Guid>();
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
ids.UnionWith(await ResolveRuleAsync(rule));
|
||||||
|
|
||||||
|
if (actingUserId is { } actor)
|
||||||
|
ids.Remove(actor);
|
||||||
|
|
||||||
|
if (ids.Count == 0) return [];
|
||||||
|
return await repo.FilterActiveUserIdsAsync(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<Guid>> ResolveRuleAsync(RecipientRule rule)
|
||||||
|
{
|
||||||
|
switch (rule)
|
||||||
|
{
|
||||||
|
case UsersRule r:
|
||||||
|
return r.Ids;
|
||||||
|
|
||||||
|
case PermissionRule r:
|
||||||
|
return await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||||
|
|
||||||
|
case ScopeRule r:
|
||||||
|
return await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||||
|
|
||||||
|
case PermissionInScopeRule r:
|
||||||
|
var withPermission = await repo.GetUserIdsByPermissionAsync(r.Code);
|
||||||
|
var inScope = await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
|
||||||
|
return withPermission.Intersect(inScope);
|
||||||
|
|
||||||
|
case ReportsToRule r:
|
||||||
|
var supervisor = await repo.GetReportsToUserIdAsync(r.UserId);
|
||||||
|
return supervisor is { } s ? [s] : [];
|
||||||
|
|
||||||
|
case RouteOwnerRule r:
|
||||||
|
var owner = await repo.GetRouteOwnerUserIdAsync(r.Kind, r.Id);
|
||||||
|
return owner is { } o ? [o] : [];
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unknown recipient rule {rule.GetType().Name}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public class NotificationRepository(AppDBContext context) : INotificationRepository
|
||||||
|
{
|
||||||
|
// ── Inbox ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Notification notification) =>
|
||||||
|
await context.Notifications.AddAsync(notification);
|
||||||
|
|
||||||
|
public void Discard(Models.Notification notification)
|
||||||
|
{
|
||||||
|
foreach (var recipient in notification.Recipients)
|
||||||
|
context.Entry(recipient).State = EntityState.Detached;
|
||||||
|
context.Entry(notification).State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Models.NotificationRecipient> InboxQuery(Guid userId, bool unreadOnly)
|
||||||
|
{
|
||||||
|
var query = context.NotificationRecipients.Where(r => r.FK_UserId == userId && r.IsActive);
|
||||||
|
return unreadOnly ? query.Where(r => !r.IsRead) : query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly) =>
|
||||||
|
InboxQuery(userId, unreadOnly).CountAsync();
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize) =>
|
||||||
|
InboxQuery(userId, unreadOnly)
|
||||||
|
.Include(r => r.Notification)
|
||||||
|
.OrderByDescending(r => r.Notification.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<int> GetUnreadCountAsync(Guid userId) =>
|
||||||
|
InboxQuery(userId, unreadOnly: true).CountAsync();
|
||||||
|
|
||||||
|
public Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId) =>
|
||||||
|
context.NotificationRecipients
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == recipientId && r.FK_UserId == userId && r.IsActive);
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId) =>
|
||||||
|
InboxQuery(userId, unreadOnly: true).ToListAsync();
|
||||||
|
|
||||||
|
public void RemoveRecipient(Models.NotificationRecipient recipient) =>
|
||||||
|
context.NotificationRecipients.Remove(recipient);
|
||||||
|
|
||||||
|
// ── Recipient resolution ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.ToList();
|
||||||
|
return context.Users
|
||||||
|
.Where(u => u.IsActive && ids.Contains(u.Id))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode) =>
|
||||||
|
context.Users
|
||||||
|
.Where(u => u.IsActive && context.RolePermissions.Any(rp =>
|
||||||
|
rp.FK_RoleId == u.FK_RoleId && rp.IsActive &&
|
||||||
|
rp.Permission.IsActive && rp.Permission.Code == permissionCode))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id)
|
||||||
|
{
|
||||||
|
Guid? regionId = null;
|
||||||
|
Guid? routeId = null;
|
||||||
|
Guid? storeId = null;
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ScopeKind.Region:
|
||||||
|
regionId = id;
|
||||||
|
break;
|
||||||
|
case ScopeKind.Route:
|
||||||
|
routeId = id;
|
||||||
|
regionId = await context.Routes.Where(r => r.Id == id)
|
||||||
|
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||||
|
break;
|
||||||
|
case ScopeKind.Store:
|
||||||
|
storeId = id;
|
||||||
|
routeId = await context.Stores.Where(s => s.Id == id)
|
||||||
|
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync();
|
||||||
|
if (routeId is { } rid)
|
||||||
|
regionId = await context.Routes.Where(r => r.Id == rid)
|
||||||
|
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIds = new HashSet<Guid>();
|
||||||
|
|
||||||
|
if (regionId is { } region)
|
||||||
|
userIds.UnionWith(await context.UserRegionScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_RegionId == region)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
if (routeId is { } route)
|
||||||
|
userIds.UnionWith(await context.UserRouteScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_RouteId == route)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
if (storeId is { } store)
|
||||||
|
userIds.UnionWith(await context.UserStoreScopes
|
||||||
|
.Where(s => s.IsActive && s.FK_StoreId == store)
|
||||||
|
.Select(s => s.FK_UserId).ToListAsync());
|
||||||
|
|
||||||
|
return userIds.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Guid?> GetReportsToUserIdAsync(Guid userId) =>
|
||||||
|
context.Users.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.FK_ReportsToUserId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id)
|
||||||
|
{
|
||||||
|
Guid? routeId = kind switch
|
||||||
|
{
|
||||||
|
ScopeKind.Route => id,
|
||||||
|
ScopeKind.Store => await context.Stores.Where(s => s.Id == id)
|
||||||
|
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (routeId is not { } rid) return null;
|
||||||
|
|
||||||
|
return await context.Routes.Where(r => r.Id == rid && r.IsActive)
|
||||||
|
.Select(r => r.FK_OwnerUserId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retention ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take) =>
|
||||||
|
context.NotificationRecipients
|
||||||
|
.Where(r => r.IsRead && r.ReadAt != null && r.ReadAt < cutoff)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take) =>
|
||||||
|
context.Notifications
|
||||||
|
.Where(n => !n.Recipients.Any())
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients) =>
|
||||||
|
context.NotificationRecipients.RemoveRange(recipients);
|
||||||
|
|
||||||
|
public void RemoveNotifications(IEnumerable<Models.Notification> notifications) =>
|
||||||
|
context.Notifications.RemoveRange(notifications);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a notification for every resolved recipient and pushes it live. Best-effort: this method
|
||||||
|
/// never throws, so a notification problem can never fail the business action that triggered it.
|
||||||
|
/// Call it only AFTER the owning service's <c>uow.CommitAsync()</c> has succeeded.
|
||||||
|
/// </summary>
|
||||||
|
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationService(
|
||||||
|
INotificationRepository repo,
|
||||||
|
NotificationRecipientResolver resolver,
|
||||||
|
IHubContext<NotificationHub> hub,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ILogger<NotificationService> logger) : INotificationService
|
||||||
|
{
|
||||||
|
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// ── Trigger side ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!NotificationTypes.All.TryGetValue(type, out var definition))
|
||||||
|
{
|
||||||
|
logger.LogError("Notification type {Type} is not registered in NotificationTypes; skipped.", type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIds = await resolver.ResolveAsync(recipients, args.CreatedByUserId);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
|
||||||
|
var values = ToDictionary(args.Values);
|
||||||
|
var notification = new Models.Notification
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Severity = definition.Severity,
|
||||||
|
Title = Truncate(Render(definition.TitleTemplate, values), 150),
|
||||||
|
Message = Truncate(Render(definition.MessageTemplate, values), 500),
|
||||||
|
EntityType = args.EntityType,
|
||||||
|
EntityId = args.EntityId,
|
||||||
|
Link = args.Link,
|
||||||
|
CreatedByUserId = args.CreatedByUserId,
|
||||||
|
CreatedAt = DateTime.Now,
|
||||||
|
Recipients = userIds.Select(id => new Models.NotificationRecipient { FK_UserId = id }).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.AddAsync(notification);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
repo.Discard(notification);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushAsync(notification);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create {Type} notification for {EntityType} {EntityId}.",
|
||||||
|
type, args.EntityType, args.EntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate from persistence: a failed push still leaves the notification in the inbox.
|
||||||
|
private async Task PushAsync(Models.Notification notification)
|
||||||
|
{
|
||||||
|
foreach (var recipient in notification.Recipients)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await hub.Clients.User(recipient.FK_UserId.ToString())
|
||||||
|
.SendAsync(NotificationHub.ClientEvent, ToResponse(notification, recipient));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to push notification {NotificationId} to user {UserId}.",
|
||||||
|
notification.Id, recipient.FK_UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inbox side ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<PagedResponse<NotificationResponse>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var total = await repo.GetInboxCountAsync(userId, unreadOnly);
|
||||||
|
var rows = await repo.GetInboxAsync(userId, unreadOnly, page, pageSize);
|
||||||
|
return new PagedResponse<NotificationResponse>(page, pageSize, total,
|
||||||
|
rows.Select(r => ToResponse(r.Notification, r)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UnreadCountResponse> GetUnreadCountAsync(Guid userId) =>
|
||||||
|
new(await repo.GetUnreadCountAsync(userId));
|
||||||
|
|
||||||
|
public async Task MarkReadAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||||
|
?? throw new KeyNotFoundException("Notification not found.");
|
||||||
|
if (recipient.IsRead) return;
|
||||||
|
|
||||||
|
recipient.IsRead = true;
|
||||||
|
recipient.ReadAt = DateTime.Now;
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var unread = await repo.GetUnreadRecipientsAsync(userId);
|
||||||
|
if (unread.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
foreach (var recipient in unread)
|
||||||
|
{
|
||||||
|
recipient.IsRead = true;
|
||||||
|
recipient.ReadAt = now;
|
||||||
|
}
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var recipient = await repo.GetRecipientAsync(userId, recipientId)
|
||||||
|
?? throw new KeyNotFoundException("Notification not found.");
|
||||||
|
|
||||||
|
repo.RemoveRecipient(recipient);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NotificationResponse ToResponse(Models.Notification n, Models.NotificationRecipient r) =>
|
||||||
|
new(r.Id, n.Type, n.Severity, n.Title, n.Message, n.EntityType, n.EntityId, n.Link,
|
||||||
|
n.CreatedAt, r.IsRead, r.ReadAt);
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToDictionary(object? values) =>
|
||||||
|
values is null
|
||||||
|
? []
|
||||||
|
: values.GetType().GetProperties()
|
||||||
|
.ToDictionary(p => p.Name, p => p.GetValue(values)?.ToString() ?? string.Empty);
|
||||||
|
|
||||||
|
private static string Render(string template, Dictionary<string, string> values) =>
|
||||||
|
TokenPattern.Replace(template, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : string.Empty).Trim();
|
||||||
|
|
||||||
|
private static string Truncate(string value, int max) =>
|
||||||
|
value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public record NotificationTypeDefinition(NotificationSeverity Severity, string TitleTemplate, string MessageTemplate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of every notification type. Services must pass one of these constants to
|
||||||
|
/// <see cref="INotificationService.NotifyAsync"/> — never a string literal. Templates use {Token}
|
||||||
|
/// placeholders filled from <see cref="NotificationArgs.Values"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class NotificationTypes
|
||||||
|
{
|
||||||
|
public const string RideCancelled = "RideCancelled";
|
||||||
|
public const string RideDriverAssigned = "RideDriverAssigned";
|
||||||
|
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
|
||||||
|
public const string DeliveryTaskPartiallyDelivered = "DeliveryTaskPartiallyDelivered";
|
||||||
|
public const string SupplierClaimApproved = "SupplierClaimApproved";
|
||||||
|
public const string SupplierClaimRejected = "SupplierClaimRejected";
|
||||||
|
public const string ShopPOConfirmed = "ShopPOConfirmed";
|
||||||
|
public const string ShopPOCancelled = "ShopPOCancelled";
|
||||||
|
public const string StockLow = "StockLow";
|
||||||
|
public const string CreditLimitBreached = "CreditLimitBreached";
|
||||||
|
|
||||||
|
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All =
|
||||||
|
new Dictionary<string, NotificationTypeDefinition>
|
||||||
|
{
|
||||||
|
[RideCancelled] = new(NotificationSeverity.Critical,
|
||||||
|
"Ride cancelled", "A ride was cancelled ({CancelType}). {Reason}"),
|
||||||
|
[RideDriverAssigned] = new(NotificationSeverity.Info,
|
||||||
|
"Ride assigned", "You have been assigned to a ride."),
|
||||||
|
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
|
||||||
|
"Delivery failed", "Delivery to {StoreName} failed. {Note}"),
|
||||||
|
[DeliveryTaskPartiallyDelivered] = new(NotificationSeverity.Warning,
|
||||||
|
"Partial delivery", "Delivery to {StoreName} was only partly delivered."),
|
||||||
|
[SupplierClaimApproved] = new(NotificationSeverity.Success,
|
||||||
|
"Supplier claim approved", "Your supplier claim was approved."),
|
||||||
|
[SupplierClaimRejected] = new(NotificationSeverity.Warning,
|
||||||
|
"Supplier claim rejected", "Your supplier claim was rejected."),
|
||||||
|
[ShopPOConfirmed] = new(NotificationSeverity.Info,
|
||||||
|
"Shop order confirmed", "Shop order for {StoreName} was confirmed."),
|
||||||
|
[ShopPOCancelled] = new(NotificationSeverity.Warning,
|
||||||
|
"Shop order cancelled", "Shop order for {StoreName} was cancelled: {Reason}"),
|
||||||
|
[StockLow] = new(NotificationSeverity.Warning,
|
||||||
|
"Low stock", "{ItemName} is down to {Quantity} at {Location}."),
|
||||||
|
[CreditLimitBreached] = new(NotificationSeverity.Warning,
|
||||||
|
"Credit limit exceeded", "{StoreName} has exceeded its credit limit."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Distribution.Features.Notifications;
|
||||||
|
|
||||||
|
public enum ScopeKind { Region, Route, Store }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who
|
||||||
|
/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes
|
||||||
|
/// and their stores, a route scope covers its stores.
|
||||||
|
/// </summary>
|
||||||
|
public abstract record RecipientRule
|
||||||
|
{
|
||||||
|
/// <summary>These specific users (creator, driver, approver, route owner …).</summary>
|
||||||
|
public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids);
|
||||||
|
|
||||||
|
/// <summary>Every active user whose role holds the permission code.</summary>
|
||||||
|
public static RecipientRule Permission(string code) => new PermissionRule(code);
|
||||||
|
|
||||||
|
/// <summary>Every active user whose scope covers the region / route / store.</summary>
|
||||||
|
public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id);
|
||||||
|
|
||||||
|
/// <summary>Users who hold the permission AND whose scope covers the region / route / store.</summary>
|
||||||
|
public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) =>
|
||||||
|
new PermissionInScopeRule(code, kind, id);
|
||||||
|
|
||||||
|
/// <summary>The user's supervisor (User.FK_ReportsToUserId).</summary>
|
||||||
|
public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId);
|
||||||
|
|
||||||
|
/// <summary>The owner of the route (<see cref="ScopeKind.Route"/>) or of the store's route (<see cref="ScopeKind.Store"/>).</summary>
|
||||||
|
public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UsersRule(IReadOnlyCollection<Guid> Ids) : RecipientRule;
|
||||||
|
public sealed record PermissionRule(string Code) : RecipientRule;
|
||||||
|
public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
|
public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
|
public sealed record ReportsToRule(Guid UserId) : RecipientRule;
|
||||||
|
public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;
|
||||||
Reference in New Issue
Block a user