Merge pull request 'Dev' (#81) from dev into production

Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
2026-10-20 17:31:19 +05:30
35 changed files with 8149 additions and 104 deletions
@@ -0,0 +1,209 @@
using Distribution.Features.Notifications;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Distribution.Tests.Services;
public class NotificationServiceTests
{
private readonly Mock<INotificationRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
private readonly Mock<IHubClients> _clients = new();
private readonly Mock<IClientProxy> _proxy = new();
private readonly NotificationService _sut;
public NotificationServiceTests()
{
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
_sut = new NotificationService(
_repo.Object,
new NotificationRecipientResolver(_repo.Object),
_hub.Object,
_uow.Object,
NullLogger<NotificationService>.Instance);
}
private static NotificationArgs Args(Guid? actor = null) =>
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
// Registry
[Fact]
public void EveryNotificationTypeConstant_HasADefinition()
{
var constants = typeof(NotificationTypes)
.GetFields()
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToList();
Assert.NotEmpty(constants);
Assert.Equal(constants.Count, constants.Distinct().Count());
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
}
// NotifyAsync
[Fact]
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
{
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
{
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_ExcludesTheActingUser()
{
var actor = Guid.NewGuid();
var other = Guid.NewGuid();
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
[RecipientRule.Users(actor, other)], Args(actor));
Assert.NotNull(saved);
var recipient = Assert.Single(saved!.Recipients);
Assert.Equal(other, recipient.FK_UserId);
}
[Fact]
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
{
var a = Guid.NewGuid();
var b = Guid.NewGuid();
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
_clients.Verify(c => c.User(a.ToString()), Times.Once);
_clients.Verify(c => c.User(b.ToString()), Times.Once);
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
{
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
var ex = await Record.ExceptionAsync(() =>
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
Assert.Null(ex);
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
{
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("socket gone"));
var ex = await Record.ExceptionAsync(() =>
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
Assert.Null(ex);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
}
// Recipient rules
[Fact]
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
{
var both = Guid.NewGuid();
var permissionOnly = Guid.NewGuid();
var scopeOnly = Guid.NewGuid();
var storeId = Guid.NewGuid();
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
var ids = await new NotificationRecipientResolver(_repo.Object)
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
Assert.Equal([both], ids);
}
[Fact]
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
{
var user = Guid.NewGuid();
var supervisor = Guid.NewGuid();
var resolver = new NotificationRecipientResolver(_repo.Object);
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
}
[Fact]
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
{
var storeId = Guid.NewGuid();
var owner = Guid.NewGuid();
var resolver = new NotificationRecipientResolver(_repo.Object);
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
}
[Fact]
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
{
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
}
[Fact]
public async Task Resolve_DeduplicatesUsersAcrossRules()
{
var user = Guid.NewGuid();
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
var ids = await new NotificationRecipientResolver(_repo.Object)
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
Assert.Equal([user], ids);
}
}
+26
View File
@@ -71,6 +71,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<ReceivedCheque> ReceivedCheques { get; set; } public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
public DbSet<SupplierPayment> SupplierPayments { get; set; } public DbSet<SupplierPayment> SupplierPayments { get; set; }
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; } public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; } public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -708,6 +710,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierPaymentGRN>() modelBuilder.Entity<SupplierPaymentGRN>()
.HasIndex(a => a.FK_GoodsReceiptId); .HasIndex(a => a.FK_GoodsReceiptId);
// --- Notifications ---
modelBuilder.Entity<Notification>()
.HasIndex(n => new { n.EntityType, n.EntityId });
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.Notification)
.WithMany(n => n.Recipients)
.HasForeignKey(r => r.FK_NotificationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.FK_UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_UserId, r.IsRead });
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
.IsUnique();
// --- StoreCreditMovement --- // --- StoreCreditMovement ---
modelBuilder.Entity<StoreCreditMovement>() modelBuilder.Entity<StoreCreditMovement>()
@@ -1,4 +1,5 @@
using Distribution.Features.LoadingEvent; using Distribution.Features.LoadingEvent;
using Distribution.Features.Notifications;
using Distribution.Features.Ride; using Distribution.Features.Ride;
using Distribution.Features.ShopPO; using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
@@ -13,8 +14,18 @@ public class DeliveryTaskService(
IShopPORepository shopPORepo, IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo, ILoadingSheetRepository loadingSheetRepo,
StockMover mover, StockMover mover,
INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private const string DeliveriesLink = "/operations/deliveries";
// Route owner + managers whose scope covers the store being delivered to.
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
[
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
];
private static DeliveryTaskResponse ToResponse(DeliveryTask t) => private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id, new(t.Id,
t.FK_RideId, t.FK_RideId,
@@ -181,6 +192,13 @@ public class DeliveryTaskService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
if (!fullyDelivered && shopPO.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskPartiallyDelivered,
DeliveryRecipients(shopPO.FK_StoreId),
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
ct);
var saved = await repo.GetByIdAsync(taskId); var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!); return ToResponse(saved!);
} }
@@ -204,6 +222,13 @@ public class DeliveryTaskService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
if (task.ShopPO?.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskFailed,
DeliveryRecipients(task.ShopPO.FK_StoreId),
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
ct);
return ToResponse(task); return ToResponse(task);
} }
} }
@@ -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;
+51 -1
View File
@@ -1,4 +1,6 @@
using Distribution.Features.Stock; using Distribution.Features.DeliveryRide;
using Distribution.Features.Notifications;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile; using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,9 +12,14 @@ public class RideService(
IRideRepository rideRepo, IRideRepository rideRepo,
IDriverProfileRepository driverRepo, IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo, IDriverComplianceConfigRepository complianceRepo,
IDeliveryTaskRepository taskRepo,
StockMover mover, StockMover mover,
INotificationService notifications,
ILogger<RideService> logger,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private const string RidesLink = "/operations/rides";
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
{ {
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId); var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
@@ -72,6 +79,12 @@ public class RideService(
try { rideRepo.Update(ride); await uow.CommitAsync(ct); } try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await notifications.NotifyAsync(
NotificationTypes.RideDriverAssigned,
[RecipientRule.Users(driver.FK_UserId)],
new NotificationArgs(null, "Ride", rideId, RidesLink),
ct);
var saved = await rideRepo.GetByIdAsync(rideId); var saved = await rideRepo.GetByIdAsync(rideId);
return ToResponse(saved!); return ToResponse(saved!);
} }
@@ -125,9 +138,46 @@ public class RideService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyRideCancelledAsync(ride, request, userId, ct);
return ToResponse(ride); return ToResponse(ride);
} }
// A ride has no route of its own; the people to tell are the owners and managers of the routes
// its delivery tasks go to. Best-effort: the ride is already cancelled, so nothing here may throw.
private async Task NotifyRideCancelledAsync(DeliveryRun ride, CancelRideRequest request, Guid userId, CancellationToken ct)
{
try
{
var tasks = await taskRepo.GetByRideIdAsync(ride.Id);
var routeIds = tasks
.Where(t => t.ShopPO?.Store is not null)
.Select(t => t.ShopPO.Store.FK_RouteId)
.Distinct()
.ToList();
var recipients = new List<RecipientRule>();
foreach (var routeId in routeIds)
{
recipients.Add(RecipientRule.RouteOwner(ScopeKind.Route, routeId));
recipients.Add(RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Route, routeId));
}
if (recipients.Count == 0) return;
await notifications.NotifyAsync(
NotificationTypes.RideCancelled,
recipients,
new NotificationArgs(
new { CancelType = request.TypeCode, Reason = request.Reason },
"Ride", ride.Id, RidesLink, userId),
ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to build RideCancelled notification for ride {RideId}.", ride.Id);
}
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId) private async Task CheckDriverComplianceAsync(Guid driverProfileId)
{ {
var driver = await driverRepo.GetByIdAsync(driverProfileId) var driver = await driverRepo.GetByIdAsync(driverProfileId)
@@ -1,4 +1,5 @@
using Distribution.Features.Invoice; using Distribution.Features.Invoice;
using Distribution.Features.Notifications;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
@@ -10,8 +11,17 @@ public class ShopPOService(
IShopPORepository repo, IShopPORepository repo,
IInvoiceRepository invoiceRepo, IInvoiceRepository invoiceRepo,
PriceListService priceListService, PriceListService priceListService,
INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow)
{ {
// Tells the person who raised the order (and their supervisor) what happened to it, unless they did it themselves.
private Task NotifyOrderAsync(string type, Models.ShopPO po, Guid actingUserId, string? reason, CancellationToken ct) =>
notifications.NotifyAsync(
type,
[RecipientRule.Users(po.RaisedById), RecipientRule.ReportsTo(po.RaisedById)],
new NotificationArgs(new { StoreName = po.Store?.StoreName, Reason = reason }, "ShopPO", po.Id, "/sales/orders", actingUserId),
ct);
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) => private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty, new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber, p.Store?.StoreAddress, p.Store?.ContactNumber,
@@ -175,6 +185,8 @@ public class ShopPOService(
try { repo.Update(po); await uow.CommitAsync(ct); } try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, confirmedBy, null, ct);
return await ToResponseWithUserAsync(po); return await ToResponseWithUserAsync(po);
} }
@@ -211,6 +223,8 @@ public class ShopPOService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOConfirmed, po, userId, null, ct);
var saved = await repo.GetByIdAsync(id); var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!); return await ToResponseWithUserAsync(saved!);
} }
@@ -238,6 +252,8 @@ public class ShopPOService(
try { repo.Update(po); await uow.CommitAsync(ct); } try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyOrderAsync(NotificationTypes.ShopPOCancelled, po, cancelledBy, po.CancelReason, ct);
return await ToResponseWithUserAsync(po); return await ToResponseWithUserAsync(po);
} }
} }
+15 -1
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Notifications;
using Distribution.Features.Scheme; using Distribution.Features.Scheme;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.StoreCredit; using Distribution.Features.StoreCredit;
@@ -7,7 +8,7 @@ using Distribution.Models;
namespace Distribution.Features.Store; namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo) public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, INotificationService notifications, IStoreCreditMovementRepository creditRepo)
{ {
private const int GlobalScopeRoleLevel = 50; private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20; private const int CreditApproverRoleLevel = 20;
@@ -148,6 +149,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id); var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null; if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null; if (!await HasStoreScopeAsync(id)) return null;
var wasWithinLimit = store.OutstandingBalance <= store.CreditLimit;
store.OutstandingBalance = request.NewBalance; store.OutstandingBalance = request.NewBalance;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
@@ -160,6 +162,18 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct); await uow.RollbackAsync(ct);
throw; throw;
} }
// Only on the crossing, so repeated updates while over the limit don't re-alert.
if (store.CreditLimit > 0 && wasWithinLimit && store.OutstandingBalance > store.CreditLimit)
await notifications.NotifyAsync(
NotificationTypes.CreditLimitBreached,
[
RecipientRule.RouteOwner(ScopeKind.Store, store.Id),
RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, store.Id),
],
new NotificationArgs(new { store.StoreName }, "Store", store.Id, $"/stores/{store.Id}", currentUser.UserId),
ct);
return ToResponse(store); return ToResponse(store);
} }
@@ -1,4 +1,5 @@
using Distribution.Features.Grn; using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Po; using Distribution.Features.Po;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -9,6 +10,7 @@ public class SupplierClaimService(
ISupplierClaimRepository repo, ISupplierClaimRepository repo,
IPoRepository poRepo, IPoRepository poRepo,
IGrnRepository grnRepo, IGrnRepository grnRepo,
INotificationService notifications,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) => private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
@@ -149,9 +151,19 @@ public class SupplierClaimService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimApproved, claim, approverId, ct);
return Map(claim); return Map(claim);
} }
// Tells whoever raised the claim how it was decided (never the person who decided it).
private Task NotifyDecisionAsync(string type, SupplierClaim claim, Guid decidedBy, CancellationToken ct) =>
notifications.NotifyAsync(
type,
[RecipientRule.Users(claim.RaisedByUserId)],
new NotificationArgs(null, "SupplierClaim", claim.Id, "/returns-claims/supplier-claims", decidedBy),
ct);
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default) public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
{ {
var claim = await repo.GetByIdAsync(id) var claim = await repo.GetByIdAsync(id)
@@ -172,6 +184,8 @@ public class SupplierClaimService(
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
await NotifyDecisionAsync(NotificationTypes.SupplierClaimRejected, claim, approverId, ct);
return Map(claim); return Map(claim);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Type = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Severity = table.Column<int>(type: "int", nullable: false),
Title = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Message = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EntityType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EntityId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Link = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "NotificationRecipients",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_NotificationId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
ReadAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationRecipients", x => x.Id);
table.ForeignKey(
name: "FK_NotificationRecipients_Notifications_FK_NotificationId",
column: x => x.FK_NotificationId,
principalTable: "Notifications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_NotificationRecipients_Users_FK_UserId",
column: x => x.FK_UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_NotificationRecipients_FK_NotificationId_FK_UserId",
table: "NotificationRecipients",
columns: new[] { "FK_NotificationId", "FK_UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NotificationRecipients_FK_UserId_IsRead",
table: "NotificationRecipients",
columns: new[] { "FK_UserId", "IsRead" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_EntityType_EntityId",
table: "Notifications",
columns: new[] { "EntityType", "EntityId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NotificationRecipients");
migrationBuilder.DropTable(
name: "Notifications");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Distribution.Data; using Distribution.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -1278,6 +1278,93 @@ namespace Distribution.Migrations
b.ToTable("LoadingSheetOrders", (string)null); b.ToTable("LoadingSheetOrders", (string)null);
}); });
modelBuilder.Entity("Distribution.Models.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("EntityId")
.HasColumnType("char(36)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Link")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("varchar(150)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("varchar(60)");
b.HasKey("Id");
b.HasIndex("EntityType", "EntityId");
b.ToTable("Notifications");
});
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_NotificationId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_UserId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("ReadAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("FK_NotificationId", "FK_UserId")
.IsUnique();
b.HasIndex("FK_UserId", "IsRead");
b.ToTable("NotificationRecipients");
});
modelBuilder.Entity("Distribution.Models.Payment", b => modelBuilder.Entity("Distribution.Models.Payment", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2528,7 +2615,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("be712eda-98a5-4a30-820a-1737e84d1e4b"), Id = new Guid("1ce4ca63-cda9-4e52-a33a-d9f817b3af42"),
IsActive = true IsActive = true
}, },
new new
@@ -2536,7 +2623,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("47ea9420-d6a0-4376-a2b8-211639d95b27"), Id = new Guid("fd551395-791e-4ec0-8acc-90fee0fdf0e4"),
IsActive = true IsActive = true
}, },
new new
@@ -2544,7 +2631,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("369eed1f-df4c-4131-a3c6-ebbe63d2dc91"), Id = new Guid("eed0b3b9-c142-4d85-a03c-a4d5047ed839"),
IsActive = true IsActive = true
}, },
new new
@@ -2552,7 +2639,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("14b6944f-2d4a-4378-a974-c6fc0f9dfb81"), Id = new Guid("fe36f257-7b58-4afe-a950-33e54623a3b0"),
IsActive = true IsActive = true
}, },
new new
@@ -2560,7 +2647,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("14a01e0b-7be5-4c65-ae6a-899f9aa7ad28"), Id = new Guid("b7fd5318-8487-4b86-9651-cdafbcd20776"),
IsActive = true IsActive = true
}, },
new new
@@ -2568,7 +2655,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("5d486ccd-3866-4e7d-8ef1-fb177fb637c1"), Id = new Guid("8f84fb31-063f-49b7-bec8-3a65f1bbc66e"),
IsActive = true IsActive = true
}, },
new new
@@ -2576,7 +2663,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000007"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("d830e59b-f451-4bde-9a77-78daea5f9a61"), Id = new Guid("8f181d4a-4eab-4b3a-88bf-a228f544969a"),
IsActive = true IsActive = true
}, },
new new
@@ -2584,7 +2671,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("096d3837-a649-4184-8987-2ea0dfa2d4a1"), Id = new Guid("fd54d83b-b79c-4c47-bc0d-7c728cab064f"),
IsActive = true IsActive = true
}, },
new new
@@ -2592,7 +2679,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("5e43bbde-689b-4d07-baf9-0764078b5586"), Id = new Guid("4306aada-781c-4618-b10c-a937b4760892"),
IsActive = true IsActive = true
}, },
new new
@@ -2600,7 +2687,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("f317d30c-1644-44f0-b0aa-6bcf1fa66f44"), Id = new Guid("4b74304c-e3a0-4366-9eca-91cb71ba4553"),
IsActive = true IsActive = true
}, },
new new
@@ -2608,7 +2695,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("31ffac6f-2aa8-48ee-bc87-213f35933de4"), Id = new Guid("ef3b60c8-5698-4757-93c7-ce54759f3c7b"),
IsActive = true IsActive = true
}, },
new new
@@ -2616,7 +2703,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("fb525bc4-9f16-455a-bfd5-ca6f3e7ad141"), Id = new Guid("90761b9f-0244-43da-bec6-c2e1e9c9103e"),
IsActive = true IsActive = true
}, },
new new
@@ -2624,7 +2711,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("2388918f-6392-4384-b269-0a1469f5bf4c"), Id = new Guid("6bd386df-32b4-434b-8d16-7f1555f92848"),
IsActive = true IsActive = true
}, },
new new
@@ -2632,7 +2719,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("7159012e-5f25-4881-b5e8-5300e382332e"), Id = new Guid("5e64183c-9fe3-48f1-a236-91df9de1378a"),
IsActive = true IsActive = true
}, },
new new
@@ -2640,7 +2727,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("cc4b2e82-ad42-4440-92ba-9b2100bb5f84"), Id = new Guid("b0e3f065-ccbe-4a6a-9c29-29986868e587"),
IsActive = true IsActive = true
}, },
new new
@@ -2648,7 +2735,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("6b15c133-ea0e-473d-b58a-7f140e9f03bf"), Id = new Guid("cd191ac2-c1a5-454f-80a6-ffa5770a3699"),
IsActive = true IsActive = true
}, },
new new
@@ -2656,7 +2743,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("c2dd52a9-380f-479f-8e5e-6b56bfc4738f"), Id = new Guid("dba4d3dd-60a1-4445-a327-22026ca35f77"),
IsActive = true IsActive = true
}, },
new new
@@ -2664,7 +2751,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("bf5f6c7a-2fde-4a01-83f4-aacf91333c4c"), Id = new Guid("c6a75c84-7992-46b5-a1f9-cdf1bf3e77a4"),
IsActive = true IsActive = true
}, },
new new
@@ -2672,7 +2759,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("162a0346-c4c0-4c4e-96e9-aa8154d25b39"), Id = new Guid("a5d1be1b-2fe9-49f9-9256-9da61a7589ae"),
IsActive = true IsActive = true
}, },
new new
@@ -2680,7 +2767,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("5cc76807-25d1-45e5-9da3-cfd6d6f3ad7c"), Id = new Guid("bdbeb46e-3a54-4ae2-90a6-e9b4450b83d3"),
IsActive = true IsActive = true
}, },
new new
@@ -2688,7 +2775,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("51a2653e-4b1a-4f6f-80c2-f533b26109ea"), Id = new Guid("eeab7811-ff26-4e4f-9813-6c10cd6c6504"),
IsActive = true IsActive = true
}, },
new new
@@ -2696,7 +2783,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("e2a382e2-d712-4a97-a7ec-cd6232d77a78"), Id = new Guid("c31fc81f-3e25-422f-81dd-dc6621aad5a4"),
IsActive = true IsActive = true
}, },
new new
@@ -2704,7 +2791,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("b60260b5-70b0-4f9e-9ab9-ba4abbbdfb02"), Id = new Guid("11d9afa6-fecd-494a-9264-cd57f7edc552"),
IsActive = true IsActive = true
}, },
new new
@@ -2712,7 +2799,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("a73fe794-383c-4157-89cf-227c4e487a6d"), Id = new Guid("3e704029-4b4b-40b0-b202-97fbf266ec31"),
IsActive = true IsActive = true
}, },
new new
@@ -2720,7 +2807,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("a9743e96-15b2-4c1d-b410-698cd6db0b7b"), Id = new Guid("fcaea017-aae4-4a42-92d2-da2b994169d9"),
IsActive = true IsActive = true
}, },
new new
@@ -2728,7 +2815,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("a94d92ab-66d8-4dd9-8ba3-c242eb450e68"), Id = new Guid("a58856db-d72b-4d06-a56b-b34861e94551"),
IsActive = true IsActive = true
}, },
new new
@@ -2736,7 +2823,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("9cb1a028-a98e-492a-aafc-70456fe46c96"), Id = new Guid("2795cbf1-de83-48d2-b955-818e30d31659"),
IsActive = true IsActive = true
}, },
new new
@@ -2744,7 +2831,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("5bc99a62-f5d7-48fe-b25c-518e4774614c"), Id = new Guid("cd60d268-87bf-44a0-9414-cfbd7dc8b30c"),
IsActive = true IsActive = true
}, },
new new
@@ -2752,7 +2839,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("eceee678-5cee-4ee9-b16d-8d8d21fbb5ea"), Id = new Guid("7b435bfb-7fd4-4f26-a746-df77fbb69b64"),
IsActive = true IsActive = true
}, },
new new
@@ -2760,7 +2847,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("3a049ed7-75b7-4b1d-b3bb-402ca77c350f"), Id = new Guid("519222bb-2021-42ec-a888-ffbbef3ebebf"),
IsActive = true IsActive = true
}, },
new new
@@ -2768,7 +2855,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("aeaab85c-5b02-4bc2-b0e3-318c0f5b07d3"), Id = new Guid("e629ea0e-70e6-4ff5-8f5e-dffbcac162ba"),
IsActive = true IsActive = true
}, },
new new
@@ -2776,7 +2863,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("b79800e0-baa6-4add-9374-7d1e2ed650e8"), Id = new Guid("34d421af-e9cb-4eb5-ab13-afd5d26749f9"),
IsActive = true IsActive = true
}, },
new new
@@ -2784,7 +2871,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("0da6bf0b-26ff-46b9-bb8c-f9c641b37a89"), Id = new Guid("9b2af108-27a1-4cfc-a0d5-ad6a54ef9804"),
IsActive = true IsActive = true
}, },
new new
@@ -2792,7 +2879,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("ff84aeeb-93c0-465d-9a4a-8b686ba7bc75"), Id = new Guid("431ed966-2f97-45bb-a28e-330ebfbd90ee"),
IsActive = true IsActive = true
}, },
new new
@@ -2800,7 +2887,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("921f8073-7a66-49bb-ae23-cf8fcde12c9c"), Id = new Guid("11ed5a9f-a14f-4e7a-ade1-d9b39e30c1e5"),
IsActive = true IsActive = true
}, },
new new
@@ -2808,7 +2895,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("bff05501-4988-4c8a-b61d-e32389e795a8"), Id = new Guid("ec51a647-7a5a-4bab-a44c-aadced5448ea"),
IsActive = true IsActive = true
}, },
new new
@@ -2816,7 +2903,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("2c4f7432-e697-4d65-97df-069f8913a662"), Id = new Guid("87cd6a37-e18a-4c43-aeeb-ab6d7f866e6b"),
IsActive = true IsActive = true
}, },
new new
@@ -2824,7 +2911,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("18d27746-3178-4c57-afd5-b75f0061637f"), Id = new Guid("ca2ad12a-65d0-41b7-9ae8-9ac8df117d01"),
IsActive = true IsActive = true
}, },
new new
@@ -2832,7 +2919,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("a5d1978b-c497-4314-857b-a7e3b8323a53"), Id = new Guid("817d6c94-11c3-4733-9fe5-c19f56316200"),
IsActive = true IsActive = true
}, },
new new
@@ -2840,7 +2927,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("88300763-b8c3-4bd2-803b-df0e43bc691e"), Id = new Guid("5b7f95c1-ddb3-4ce8-b62f-11f3c37c44b2"),
IsActive = true IsActive = true
}, },
new new
@@ -2848,7 +2935,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("7aaad5a9-4497-429f-b3dc-9b59664e2e19"), Id = new Guid("a84f7909-8f45-4ca3-a10f-2fc17680dfc0"),
IsActive = true IsActive = true
}, },
new new
@@ -2856,7 +2943,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("2db130c1-4b6f-4719-b3bb-7bd8a0662d75"), Id = new Guid("2af26741-1110-4853-82aa-70639060c087"),
IsActive = true IsActive = true
}, },
new new
@@ -2864,7 +2951,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("be349d14-38b5-4a13-a152-9ea055de26b1"), Id = new Guid("f2267efb-00c3-49f2-8f5d-c9cabd6cc1ec"),
IsActive = true IsActive = true
}, },
new new
@@ -2872,7 +2959,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("5292e02d-9a7d-4a18-be14-86c2f3351cac"), Id = new Guid("6026dc21-65d6-4754-8e08-7a8588b41020"),
IsActive = true IsActive = true
}, },
new new
@@ -2880,7 +2967,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("97021474-1218-4d21-9115-4b9b2726e38b"), Id = new Guid("769408c0-fa97-4234-9ea8-a4e5a1a847be"),
IsActive = true IsActive = true
}, },
new new
@@ -2888,7 +2975,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("56f68355-7b1b-49e4-829c-1a4d47fc93de"), Id = new Guid("bace0a50-82ca-4d44-88f2-d0ecef753b0a"),
IsActive = true IsActive = true
}, },
new new
@@ -2896,7 +2983,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("3017c8cb-f9e4-41c8-ac2e-f03d23fe944a"), Id = new Guid("9043c1e0-cf7b-4ac3-affd-52b0706d1753"),
IsActive = true IsActive = true
}, },
new new
@@ -2904,7 +2991,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("619a08e5-f6cb-4515-8b4d-a094d19277d8"), Id = new Guid("b4572222-2bcc-4d18-aa82-d5fdb2a3f4ae"),
IsActive = true IsActive = true
}, },
new new
@@ -2912,7 +2999,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("cb8b4e96-7026-4485-8b52-3145b5289350"), Id = new Guid("2f6ab72b-a3f2-4a47-8681-0ee575c9998f"),
IsActive = true IsActive = true
}, },
new new
@@ -2920,7 +3007,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"), FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"), FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("f0f57dfd-21a5-4a0b-a77e-e641f00300fa"), Id = new Guid("395971d4-1369-45d4-8b54-28c1a33b7e0c"),
IsActive = true IsActive = true
}); });
}); });
@@ -5015,6 +5102,25 @@ namespace Distribution.Migrations
b.Navigation("ShopPO"); b.Navigation("ShopPO");
}); });
modelBuilder.Entity("Distribution.Models.NotificationRecipient", b =>
{
b.HasOne("Distribution.Models.Notification", "Notification")
.WithMany("Recipients")
.HasForeignKey("FK_NotificationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.User", "User")
.WithMany()
.HasForeignKey("FK_UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Notification");
b.Navigation("User");
});
modelBuilder.Entity("Distribution.Models.Payment", b => modelBuilder.Entity("Distribution.Models.Payment", b =>
{ {
b.HasOne("Distribution.Models.BankAccount", "BankAccount") b.HasOne("Distribution.Models.BankAccount", "BankAccount")
@@ -5732,6 +5838,11 @@ namespace Distribution.Migrations
b.Navigation("Orders"); b.Navigation("Orders");
}); });
modelBuilder.Entity("Distribution.Models.Notification", b =>
{
b.Navigation("Recipients");
});
modelBuilder.Entity("Distribution.Models.PriceList", b => modelBuilder.Entity("Distribution.Models.PriceList", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");
+33
View File
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace Distribution.Models
{
public enum NotificationSeverity { Info, Success, Warning, Critical }
/// <summary>One row per event. Who it was delivered to lives in <see cref="NotificationRecipient"/>.</summary>
public class Notification : Template
{
[MaxLength(60)]
public string Type { get; set; } = string.Empty;
public NotificationSeverity Severity { get; set; }
[MaxLength(150)]
public string Title { get; set; } = string.Empty;
[MaxLength(500)]
public string Message { get; set; } = string.Empty;
[MaxLength(50)]
public string EntityType { get; set; } = string.Empty;
public Guid EntityId { get; set; }
[MaxLength(200)]
public string Link { get; set; } = string.Empty;
public Guid? CreatedByUserId { get; set; }
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
}
}
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>One row per user per notification, so read state is per user.</summary>
public class NotificationRecipient : Template
{
public Guid FK_NotificationId { get; set; }
[ForeignKey("FK_NotificationId")]
public Notification Notification { get; set; }
public Guid FK_UserId { get; set; }
[ForeignKey("FK_UserId")]
public User User { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
}
}
+11
View File
@@ -12,6 +12,7 @@ using Distribution.Features.ReceivedCheque;
using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile; using Distribution.Features.DriverProfile;
using Distribution.Features.Grn; using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.ShopPO; using Distribution.Features.ShopPO;
using Distribution.Features.Invoice; using Distribution.Features.Invoice;
@@ -142,6 +143,15 @@ builder.Services.AddScoped<ReceivedChequeService>();
builder.Services.AddScoped<SupplierPaymentService>(); builder.Services.AddScoped<SupplierPaymentService>();
builder.Services.AddScoped<SysConfigService>(); builder.Services.AddScoped<SysConfigService>();
builder.Services.AddScoped<INotificationRepository, NotificationRepository>();
builder.Services.AddScoped<NotificationRecipientResolver>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<INotificationService>(sp => sp.GetRequiredService<NotificationService>());
builder.Services.AddSingleton<Microsoft.AspNetCore.SignalR.IUserIdProvider, NotificationUserIdProvider>();
builder.Services.AddHostedService<NotificationCleanupJob>();
builder.Services.AddSignalR()
.AddJsonProtocol(options => options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@@ -193,5 +203,6 @@ app.UseMiddleware<AuthMiddleware>();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapHub<NotificationHub>(NotificationHub.Path);
app.Run(); app.Run();
+297 -34
View File
@@ -1,67 +1,330 @@
# 21 — Notifications & Alerts # 21 — Notifications & Alerts
**Status**: Not Started **Status**: v1 in-app built — backend, SignalR hub, frontend bell + view-all page, 10 unit tests. EF migration `AddNotifications` is generated but **not applied** to any database. Six of the seven Phase 1 triggers are wired into the services (see **Triggers**); `StockLow` is deferred. The temporary test endpoint and button were removed.
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls **Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
--- ---
## Goal ## Goal
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already ([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
produce.
Real-time uses **SignalR**. A notification is **saved first, pushed second**, so offline users see
what they missed on their next load.
--- ---
## Background / Context ## Implemented now — who gets which notification
No notification channel exists in this codebase today. This step is purely the delivery mechanism Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in | # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
[12_store_management.md](./12_store_management.md), route deviation logic in |---|---|---|---|---|---|---|---|
[09_fraud_controls.md](./09_fraud_controls.md)). | 1 | Shop order **confirmed** | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` | **Shop order confirmed** — "Shop order for *Store* was confirmed." | The user who raised the order + that user's supervisor | The person who confirmed it | Info | `/sales/orders` |
| 2 | Shop order **cancelled** | `ShopPOService.CancelAsync` | **Shop order cancelled** — "Shop order for *Store* was cancelled: *reason*" | The user who raised the order + that user's supervisor | The person who cancelled it | Warning | `/sales/orders` |
| 3 | Supplier claim **approved** | `SupplierClaimService.ApproveAsync` | **Supplier claim approved** | The user who raised the claim | The approver | Success | `/returns-claims/supplier-claims` |
| 4 | Supplier claim **rejected** | `SupplierClaimService.RejectAsync` | **Supplier claim rejected** | The user who raised the claim | The person who rejected it | Warning | `/returns-claims/supplier-claims` |
| 5 | **Driver assigned** to a ride | `RideService.AssignDriverAsync` | **Ride assigned** — "You have been assigned to a ride." | The assigned driver | Everyone else | Info | `/operations/rides` |
| 6 | Ride **cancelled** | `RideService.CancelAsync` | **Ride cancelled** — "A ride was cancelled (*type*). *reason*" | The owner of every route in the ride, plus users with `delivery-task.manage` whose scope covers those routes | The person who cancelled it | Critical | `/operations/rides` |
| 7 | Delivery **failed** | `DeliveryTaskService.FailAsync` | **Delivery failed** — "Delivery to *Store* failed. *note*" | The owner of the store's route, plus users with `delivery-task.manage` whose scope covers the store | Nobody is excluded (this action does not record who did it) | Warning | `/operations/deliveries` |
| 8 | Delivery **partly delivered** | `DeliveryTaskService.ConfirmAsync` | **Partial delivery** — "Delivery to *Store* was only partly delivered." | Same as #7 | The person who confirmed it | Warning | `/operations/deliveries` |
| 9 | Store **credit limit crossed** | `StoreService.UpdateOutstandingBalanceAsync` | **Credit limit exceeded** — "*Store* has exceeded its credit limit." | The owner of the store's route, plus users with `store.manage` whose scope covers the store | The person who updated the balance. Fires only when the balance goes from within the limit to above it | Warning | `/stores/{id}` |
**Not wired yet:** low stock (`StockLow`), cheque returned / bounced / due, expiring batches, overdue payments, and the Phase 2 list below.
### Three rules that decide who sees what
1. **You are never notified of your own action.**
2. **Receiving needs no permission** — any logged-in user gets the bell and the page. Permissions only matter for *doing* the action, and for the "managers in scope" recipients (they need the permission **and** a scope covering the store or route).
3. **SuperAdmin usually acts, rarely receives.** It holds every permission but has no scope rows in the seed, so it is only a recipient when it created the record (orders, claims) or owns the route.
### With two logins — SuperAdmin and a sales rep
| Who does it | Action | Who gets the notification |
|---|---|---|
| Rep creates, **SuperAdmin** confirms or cancels | Shop order | **Rep** (creator), plus the rep's supervisor if they have one |
| **SuperAdmin** creates, rep confirms or cancels | Shop order | **SuperAdmin** (creator) |
| SuperAdmin | Sets a store's outstanding balance above its limit | The **rep**, if the rep owns that store's route. SuperAdmin never (actor) |
| SuperAdmin | Assigns a driver to a ride | The **rep**, only if the rep is that driver |
| SuperAdmin | Approves or rejects a supplier claim | Whoever raised the claim — the **rep** only if the rep raised it |
| Anyone | Fails a delivery / cancels a ride | The **rep**, if the rep owns a route involved. SuperAdmin only if it has a scope on that route |
Seed route owners: `nimal.perera` owns the Colombo and Negombo routes; `kamal.silva` owns Kandy and Matale.
### Quick test (two browsers, one login each, both on a dashboard page)
1. As the **rep**, create a shop order (Sales → New).
2. As **SuperAdmin**, confirm it, then create a second one and cancel it with a reason.
3. The rep sees a toast, a red badge on the bell and a new row on `/notifications` — "Shop order confirmed" and "Shop order cancelled: *reason*". Clicking one marks it read and opens `/sales/orders`.
4. Reverse it: SuperAdmin creates an order and the rep confirms or cancels it — SuperAdmin is notified.
5. Credit limit: there is no screen for it, so use Swagger (`/swagger`): `PUT /api/stores/{id}/outstanding-balance` with `{ "newBalance": 60000 }` on a store whose limit is lower (Fort Mini Mart: limit 50,000). A second call with a higher value does not alert again; drop the balance back under the limit and set it over again to re-trigger.
--- ---
## Data Model Changes ## Flow
### Notification ```
Service action ──► uow.CommitAsync() (business transaction, unchanged)
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`, │
`ReadAt` (nullable). ▼ only after a successful commit
NotificationService.NotifyAsync(type, recipientRule, args, entity)
1. resolve recipients (users | permission | scope | reports-to)
2. insert Notification + NotificationRecipient rows
3. hub.Clients.Users(ids).SendAsync("notification", dto)
▼
Frontend provider: connection.on("notification") → badge + toast → click opens Link + marks read
```
--- ---
## API Endpoints ## Decisions
| Function reference | Endpoint intent | | Question | Decision |
|---|---| |---|---|
| `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) | | First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` | | Channels | In-app only (SignalR). Email / SMS / push deferred |
| `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) | | Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. | | Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
| — | `GET /api/notifications` — recipient's own notification inbox | | CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
| — | `PUT /api/notifications/{id}/read` | | Recipients | By **permission code** and scope, not role name. The seeded roles are `SalesRep`, `Supervisor`, `AreaManager`, `AreaSupervisor`, `RegionalManager`, `RegionalAgency`, `SuperAdmin`; there is no Accounts / operations role |
| Ids | `User`, `Permission` and entity ids are **Guid** (see seeds) |
--- ---
## Business Rules ## Data Model
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing **Notification** — one row per event
endpoints are inbox read/list.
- Notification delivery failures (e.g. SMS provider down) must not roll back the business | Field | Type | Notes |
transaction that triggered them — fire-and-forget, logged separately. |---|---|---|
| `Id` | Guid PK | |
| `Type` | varchar(60) | `NotificationTypes` constant; frontend maps it to an icon |
| `Severity` | enum | `Info`, `Success`, `Warning`, `Critical` |
| `Title` / `Message` | varchar(150) / varchar(500) | Built from the type's templates |
| `EntityType` / `EntityId` | varchar(50) / Guid | The related record |
| `Link` | varchar(200) | Frontend route, e.g. `/payments/received-cheques/88` |
| `CreatedByUserId` | FK User, null | Null for system / scheduled events |
| `CreatedAt` | datetime | `DateTime.Now` |
| `DedupeKey` | — | **Not in v1.** Added with the Phase 1b scheduled jobs, e.g. `ItemBatchExpiring:77:2026-10-20` |
**NotificationRecipient** — one row per user per event (read state is per user)
| Field | Type |
|---|---|
| `Id` | Guid PK |
| `NotificationId` | FK |
| `UserId` | FK |
| `IsRead` / `ReadAt` | bool / datetime null |
Index `(UserId, IsRead)`; the bell orders by the joined `Notification.CreatedAt DESC`.
No config table in v1 — every type is enabled. (Later: `NotificationModuleConfig` with `Type` PK +
`InAppEnabled`, missing row = enabled.)
Example (failed delivery):
```
Notification: Type=DeliveryTaskFailed, Severity=Warning,
Title="Delivery failed", Message="Delivery to Fort Mini Mart failed.",
EntityType=DeliveryTask, EntityId=<guid>, Link=/operations/delivery-tasks/<guid>
NotificationRecipient: (that notification, Nimal Perera - route owner, unread)
(that notification, a delivery-task.manage user in Western region, unread)
```
---
## Recipients
Every trigger names its rule. There is **no "notify everyone"** option.
| Rule | Resolves to |
|---|---|
| `Users(ids)` | Those users (creator, driver, approver, claim owner) |
| `Permission(code)` | Active users whose role has that permission code (`User.FK_RoleId` → `RolePermission` → `Permission.Code`), e.g. `delivery-task.manage` |
| `Scope(kind, id)` | Users whose `UserStoreScope` / `UserRegionScope` / `UserRouteScope` covers the record |
| `PermissionInScope(code, kind, id)` | Intersection of the two (e.g. `store.manage` users for that store's region) |
| `ReportsTo(userId)` | The user's supervisor via `User.FK_ReportsToUserId` |
| `RouteOwner(kind, id)` | The owner (`Route.FK_OwnerUserId`) of the route, or of the store's route when `kind` is `Store` |
`SuperAdmin` holds every permission, so it receives `Permission` rules automatically; use
`PermissionInScope` where that would be too noisy.
The resolver always drops inactive users and the acting user, and de-duplicates.
---
## Triggers
Each is called **after** the owning service's `uow.CommitAsync()`.
**Phase 1 — must have**
| Type | Raised by | When | Recipients | Severity |
|---|---|---|---|---|
| Type | Status | Raised by | When | Recipients | Severity |
|---|---|---|---|---|---|
| `SupplierClaimApproved` / `Rejected` | ✅ wired | `SupplierClaimService.ApproveAsync` / `RejectAsync` | Decision | `Users(RaisedByUserId)` (not the decider) | Success / Warning |
| `RideCancelled` | ✅ wired | `RideService.CancelAsync` | Cancelled (message carries `CancelRequestType` + reason) | For each distinct route in the ride's delivery tasks: `RouteOwner(Route)` + `PermissionInScope(delivery-task.manage, Route)`. A ride has no route of its own, so routes come from its tasks' stores | Critical |
| `RideDriverAssigned` | ✅ wired | `RideService.AssignDriverAsync` | Driver assigned | `Users(driver.FK_UserId)` | Info |
| `DeliveryTaskFailed` | ✅ wired | `DeliveryTaskService.FailAsync` | Task failed | `RouteOwner(Store)` + `PermissionInScope(delivery-task.manage, Store)` | Warning |
| `DeliveryTaskPartiallyDelivered` | ✅ wired | `DeliveryTaskService.ConfirmAsync` | Confirmed with `fullyDelivered == false` (a separate type: "failed" would be the wrong wording) | same as above | Warning |
| `ShopPOConfirmed` / `Cancelled` | ✅ wired | `ShopPOService.ConfirmAsync`, `ConfirmAndCreateInvoiceAsync` / `CancelAsync` | Status change | `Users(RaisedById)` + `ReportsTo(RaisedById)` (not the actor) | Info / Warning |
| `CreditLimitBreached` | ✅ wired | `StoreService.UpdateOutstandingBalanceAsync` | Balance crosses from ≤ to > `CreditLimit` (crossing only). This is the only place `OutstandingBalance` is ever written — invoices and payments do not update it | `RouteOwner(Store)` + `PermissionInScope(store.manage, Store)` | Warning |
| `StockLow` | ⏸ deferred | Stock movement | Crosses to ≤ `Item.ReorderLevel` | Undecided — warehouses have no region / scope link, so `PermissionInScope` cannot target them. Needs a per-request tracker + action filter because stock moves from many call sites that commit separately | Warning |
**Waiting on finance permissions** (not in the seed yet — add a cheque permission first)
`ReceivedChequeReturned` (`ReceivedChequeService.UpdateStatusAsync`, action `Return`, Critical) ·
`ChequePageBounced` (`ChequeBookService.UpdatePageStatusAsync`, action `Bounce`, Critical)
**Phase 1b — scheduled, second pass** (daily `BackgroundService`, uses `DedupeKey`)
`ReceivedChequeDueSoon` (≤ 2 days) · `ItemBatchExpiring` (≤ N days) · `PaymentOverdue` ·
`DriverComplianceExpiring`
**Phase 2 — later**
`PoCancelled` / `PoClosed` · `GrnShortOrDamaged` · `LoadingSheetConfirmed` · `InvoiceFinalized` ·
`ReturnCreated` / `ClaimSettled` · `PurchaseReturnCreated` · `ApprovalPending` ·
`RouteDeviationFlagged` · `RoleAssigned` / `ScopeChanged` · `SyncFailed`
Excluded on purpose: plain master-data CRUD (brand, category, UOM, vehicle, region).
---
## API
| Endpoint | Purpose |
|---|---|
| `GET /api/notifications?unreadOnly=&page=&pageSize=` | Caller's inbox, newest first |
| `GET /api/notifications/unread-count` | Badge count |
| `PATCH /api/notifications/{id}/read` | Mark one read (own row only) |
| `PATCH /api/notifications/read-all` | Mark all read |
| `DELETE /api/notifications/{id}` | Remove caller's recipient row |
| `WS /hubs/notifications` | SignalR; server → client event `notification` |
---
## Backend structure
```
Features/Notifications/ (namespace Distribution.Features.Notifications — "Notification" alone clashes with Models.Notification)
NotificationController.cs thin, no UoW
NotificationService.cs resolve → persist → push; owns uow.CommitAsync()
INotificationRepository.cs / NotificationRepository.cs
NotificationRecipientResolver.cs Users / Permission / Scope / PermissionInScope / ReportsTo
NotificationTypes.cs constants + definition (severity, title/message template)
NotificationDTOs.cs
NotificationHub.cs SignalR hub
NotificationCleanupJob.cs daily retention purge
NotificationScheduleJob.cs Phase 1b alerts (second pass)
Models/Notification.cs, NotificationRecipient.cs
```
### Code reference
**Registry** — services never pass string literals:
```csharp
public static class NotificationTypes
{
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
// ...
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All = new Dictionary<string, NotificationTypeDefinition>
{
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
"Delivery failed", "Delivery to {StoreName} failed."),
};
}
```
**Service contract** — never throws; failures are logged:
```csharp
public interface INotificationService
{
// recipients = union of the rules, then resolved, filtered (inactive, actor) and de-duplicated
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
}
// RecipientRule: Users(ids) | Permission(code) | Scope(kind, id) | PermissionInScope(code, kind, id) | ReportsTo(userId) | RouteOwner(kind, id)
// NotificationArgs: Values (template tokens), EntityType, EntityId (Guid), Link, CreatedByUserId (Guid?)
```
**Calling it from an existing service:**
```csharp
await _uow.CommitAsync(ct);
await _notifications.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
[RecipientRule.Users(route.FK_OwnerUserId),
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Region, route.FK_RegionId)],
new(new { StoreName = store.StoreName }, "DeliveryTask", task.Id,
$"/operations/delivery-tasks/{task.Id}", currentUserId), ct);
```
**Program.cs:**
```csharp
builder.Services.AddSignalR();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddHostedService<NotificationCleanupJob>();
// CORS: AllowCredentials() with explicit origins
app.MapHub<NotificationHub>("/hubs/notifications");
```
The hub authenticates through the existing `access-token` cookie (read by `AuthMiddleware`); the
client connects with `withCredentials: true`. A `?access_token=` query fallback is not needed unless
the token ever moves out of the cookie. Users are addressed by their `UserId` claim
(`NotificationUserIdProvider`). Each recipient gets its own push because the DTO `Id` is the
recipient row id.
**Frontend** (`contexts/notification-context.tsx`):
```tsx
const connection = new HubConnectionBuilder()
.withUrl(`${API}/hubs/notifications`, { withCredentials: true })
.withAutomaticReconnect().build();
connection.on("notification", (n: NotificationDto) => {
setUnread((c) => c + 1);
setItems((p) => [n, ...p]);
toast(n.title, { description: n.message, action: { label: "Open", onClick: () => router.push(n.link) } });
});
connection.onreconnected(refetch); // catch up on anything missed
```
Also: `lib/api/notifications.ts`, bell + popover in the dashboard header, `/notifications` "view all" page.
---
## Rules
- Notify **after** a successful commit, inside try/catch + log. A notification failure must never
fail or roll back the business action.
- Saving and the hub push are separate try/catch blocks (a failed push still leaves the inbox entry).
- Unknown type: throws in dev/test, logged and skipped in production. A unit test checks that every
constant has a definition.
- No per-type config in v1: every type is always delivered.
- Click-through is mandatory: `EntityType`, `EntityId` and `Link` are required; clicking marks read.
- Retention: `NotificationCleanupJob` deletes read recipients older than 90 days (SysConfig), then
events with no recipients left.
- No Email/SMS switches in the UI until a sender exists.
- `AGENTS.md`: `uow.CommitAsync()` only in services, never `SaveChangesAsync()`, `DateTime.Now` only.
--- ---
## Open Questions ## Open Questions
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This Resolved — see **Decisions**. Remaining for later:
significantly changes the infrastructure needed (third-party provider integration, see
[22_integration_api.md](./22_integration_api.md)). 1. Add a cheque / finance permission to the seed, then enable the two cheque triggers.
2. Email / SMS / push: see [22_integration_api.md](./22_integration_api.md).
3. If the API ever runs on more than one instance, add a SignalR Redis backplane.
4. Per-type mute (`NotificationModuleConfig` + admin screen) when requested.
--- ---
## Implementation Notes ## Build order
> Fill this section in during planning discussion before writing any code. 1. Entities (`Notification`, `NotificationRecipient`) + EF migration.
2. `Features/Notifications/` (repository, resolver, service, controller, DTOs, `NotificationTypes`) + retention job.
3. SignalR hub, `Program.cs` registration, user-id provider.
4. Frontend: API client, provider, bell, toast, click-through, view-all page.
5. Phase 1 triggers — six wired (recipient resolution is unit-tested; the per-service calls are not,
because `Distribution.Tests` does not currently compile). `StockLow` deferred.
6. Later: `StockLow`, cheque triggers, Phase 1b scheduled alerts, Phase 2 triggers.
Remaining before this works end to end: apply the `AddNotifications` migration.
+6 -1
View File
@@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
import { AppSidebar } from '@/components/layout/app-sidebar' import { AppSidebar } from '@/components/layout/app-sidebar'
import { BottomNav } from '@/components/layout/bottom-nav' import { BottomNav } from '@/components/layout/bottom-nav'
import { ThemeToggle } from '@/components/layout/theme-toggle' import { ThemeToggle } from '@/components/layout/theme-toggle'
import { NotificationBell } from '@/components/layout/notification-bell'
import { NotificationProvider } from '@/contexts/notification-context'
import { TopLoader } from '@/components/shared/top-loader' import { TopLoader } from '@/components/shared/top-loader'
export default function DashboardLayout({ export default function DashboardLayout({
@@ -10,13 +12,15 @@ export default function DashboardLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<NotificationProvider>
<SidebarProvider> <SidebarProvider>
<TopLoader /> <TopLoader />
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4"> <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" /> <SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto"> <div className="ml-auto flex items-center gap-1">
<NotificationBell />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</header> </header>
@@ -26,5 +30,6 @@ export default function DashboardLayout({
</SidebarInset> </SidebarInset>
<BottomNav /> <BottomNav />
</SidebarProvider> </SidebarProvider>
</NotificationProvider>
) )
} }
@@ -0,0 +1,133 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/shared/empty-state'
import { NotificationRow } from '@/components/layout/notification-bell'
import { useNotifications } from '@/contexts/notification-context'
import { getApiError } from '@/lib/api'
import { notifications } from '@/lib/api/notifications'
import { toast } from '@/components/ui/toast'
import type { NotificationResponse } from '@/types/notifications'
const PAGE_SIZE = 20
type Filter = 'all' | 'unread'
export function NotificationsClient() {
const { recent, unreadCount, open, markAllRead, refresh } = useNotifications()
const [filter, setFilter] = useState<Filter>('all')
const [items, setItems] = useState<NotificationResponse[]>([])
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
// The caller decides whether to show the loading state (it is true on first render).
const load = useCallback(async (targetPage: number, reset: boolean) => {
try {
const result = await notifications.list({
unreadOnly: filter === 'unread',
page: targetPage,
pageSize: PAGE_SIZE,
})
setItems(prev => (reset ? result.items : [...prev, ...result.items]))
setPage(targetPage)
setTotal(result.total)
} catch (error) {
toast.add({ title: 'Could not load notifications', description: getApiError(error), type: 'error' })
} finally {
setLoading(false)
}
}, [filter])
// Reload when the filter changes or a new notification is pushed in.
const latestId = recent[0]?.id
useEffect(() => { void load(1, true) }, [load, latestId])
async function handleOpen(n: NotificationResponse) {
setItems(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
await open(n)
}
async function handleMarkAllRead() {
await markAllRead()
await load(1, true)
}
async function handleDelete(n: NotificationResponse) {
try {
await notifications.remove(n.id)
setItems(prev => prev.filter(x => x.id !== n.id))
setTotal(t => Math.max(0, t - 1))
void refresh()
} catch (error) {
toast.add({ title: 'Could not delete notification', description: getApiError(error), type: 'error' })
}
}
const hasMore = items.length < total
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Notifications</h1>
<p className="text-sm text-muted-foreground">
{unreadCount > 0 ? `${unreadCount} unread` : 'You\'re all caught up'}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-2xl border border-border p-0.5">
{(['all', 'unread'] as const).map(f => (
<Button
key={f}
variant={filter === f ? 'secondary' : 'ghost'}
size="sm"
onClick={() => { setLoading(true); setFilter(f) }}
>
{f === 'all' ? 'All' : 'Unread'}
</Button>
))}
</div>
<Button variant="outline" size="sm" disabled={unreadCount === 0} onClick={() => { void handleMarkAllRead() }}>
Mark all read
</Button>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border">
{items.length === 0 && !loading ? (
<EmptyState
title={filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
description="Alerts about deliveries, stock, claims and orders will show up here."
/>
) : (
<div className="divide-y divide-border">
{items.map(n => (
<div key={n.id} className="group relative">
<NotificationRow notification={n} onOpen={item => { void handleOpen(item) }} />
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete notification"
className="absolute top-2 right-2 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => { void handleDelete(n) }}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</div>
{hasMore && (
<Button variant="outline" disabled={loading} onClick={() => { setLoading(true); void load(page + 1, false) }} className="self-center">
{loading ? 'Loading…' : 'Load more'}
</Button>
)}
</div>
)
}
@@ -0,0 +1,5 @@
import { NotificationsClient } from './_components/notifications-client'
export default function NotificationsPage() {
return <NotificationsClient />
}
@@ -0,0 +1,122 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { Bell } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useNotifications } from '@/contexts/notification-context'
import { cn } from '@/lib/utils'
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
const SEVERITY_DOT: Record<NotificationSeverity, string> = {
Info: 'bg-sky-500',
Success: 'bg-emerald-500',
Warning: 'bg-amber-500',
Critical: 'bg-red-500',
}
export function timeAgo(iso: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(iso).toLocaleDateString()
}
export function NotificationRow({
notification: n,
onOpen,
}: {
notification: NotificationResponse
onOpen: (n: NotificationResponse) => void
}) {
return (
<button
type="button"
onClick={() => onOpen(n)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
!n.isRead && 'bg-muted/30'
)}
>
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', n.isRead ? 'bg-transparent' : SEVERITY_DOT[n.severity])} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={cn('truncate text-sm', n.isRead ? 'text-foreground' : 'font-medium text-foreground')}>
{n.title}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{n.message}</span>
<span className="text-[11px] text-muted-foreground/70">{timeAgo(n.createdAt)}</span>
</span>
</button>
)
}
export function NotificationBell() {
const { recent, unreadCount, open, markAllRead } = useNotifications()
const [isOpen, setIsOpen] = useState(false)
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon"
className="relative h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
/>
}
>
<Bell size={16} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</PopoverTrigger>
<PopoverContent>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="xs"
disabled={unreadCount === 0}
onClick={() => { void markAllRead() }}
>
Mark all read
</Button>
</div>
<div className="max-h-96 divide-y divide-border overflow-y-auto">
{recent.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">You&apos;re all caught up.</p>
) : (
recent.map(n => (
<NotificationRow
key={n.id}
notification={n}
onOpen={item => { setIsOpen(false); void open(item) }}
/>
))
)}
</div>
<div className="border-t border-border p-2">
<Link
href="/notifications"
onClick={() => setIsOpen(false)}
className="block rounded-xl px-3 py-2 text-center text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
View all
</Link>
</div>
</PopoverContent>
</Popover>
)
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
align = "end",
alignOffset = 0,
side = "bottom",
sideOffset = 6,
className,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 w-[min(24rem,calc(100vw-2rem))] origin-(--transform-origin) rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }
@@ -0,0 +1,135 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'
import { useAuth } from '@/contexts/auth-context'
import { notifications, NOTIFICATION_HUB_URL } from '@/lib/api/notifications'
import { toast } from '@/components/ui/toast'
import type { NotificationResponse, NotificationSeverity } from '@/types/notifications'
const RECENT_COUNT = 20
const TOAST_TYPE: Record<NotificationSeverity, 'info' | 'success' | 'warning' | 'error'> = {
Info: 'info',
Success: 'success',
Warning: 'warning',
Critical: 'error',
}
interface NotificationContextValue {
/** The most recent notifications, newest first (what the bell popover shows). */
recent: NotificationResponse[]
unreadCount: number
/** Marks the notification read (if needed) and navigates to its link. */
open: (n: NotificationResponse) => Promise<void>
markAllRead: () => Promise<void>
/** Re-fetches the recent list and the unread count. */
refresh: () => Promise<void>
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
export function NotificationProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { user } = useAuth()
const [recent, setRecent] = useState<NotificationResponse[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const refresh = useCallback(async () => {
try {
const [page, count] = await Promise.all([
notifications.list({ page: 1, pageSize: RECENT_COUNT }),
notifications.unreadCount(),
])
setRecent(page.items)
setUnreadCount(count)
} catch {
// The bell is a convenience; never break the page if it can't load.
}
}, [])
const markRead = useCallback(async (n: NotificationResponse) => {
if (n.isRead) return
setRecent(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x)))
setUnreadCount(c => Math.max(0, c - 1))
try {
await notifications.markRead(n.id)
} catch {
await refresh()
}
}, [refresh])
const open = useCallback(async (n: NotificationResponse) => {
await markRead(n)
if (n.link) router.push(n.link)
}, [markRead, router])
const markAllRead = useCallback(async () => {
setRecent(prev => prev.map(x => ({ ...x, isRead: true })))
setUnreadCount(0)
try {
await notifications.markAllRead()
} catch {
await refresh()
}
}, [refresh])
// Keep the latest `open` for the long-lived socket handler without reconnecting.
const openRef = useRef(open)
useEffect(() => { openRef.current = open }, [open])
useEffect(() => {
if (!user) return
void refresh()
const connection = new HubConnectionBuilder()
.withUrl(NOTIFICATION_HUB_URL, { withCredentials: true })
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build()
connection.on('notification', (n: NotificationResponse) => {
setRecent(prev => [n, ...prev.filter(x => x.id !== n.id)].slice(0, RECENT_COUNT))
setUnreadCount(c => c + 1)
toast.add({
title: n.title,
description: n.message,
type: TOAST_TYPE[n.severity] ?? 'info',
actionProps: n.link
? { children: 'Open', onClick: () => { void openRef.current(n) } }
: undefined,
})
})
// Anything pushed while the socket was down is only in the inbox — catch up.
connection.onreconnected(() => { void refresh() })
connection.start().catch(() => {
// Initial connect failed (offline / API down); the inbox endpoints still work.
})
return () => { void connection.stop() }
}, [user, refresh])
return (
<NotificationContext.Provider
value={{
recent: user ? recent : [],
unreadCount: user ? unreadCount : 0,
open,
markAllRead,
refresh,
}}
>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications(): NotificationContextValue {
const ctx = useContext(NotificationContext)
if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider')
return ctx
}
+1
View File
@@ -16,6 +16,7 @@ export { bankAccounts } from './bank-accounts'
export { chequeBooks, chequePages } from './cheques' export { chequeBooks, chequePages } from './cheques'
export { receivedCheques } from './received-cheques' export { receivedCheques } from './received-cheques'
export { supplierPayments } from './supplier-payments' export { supplierPayments } from './supplier-payments'
export { notifications } from './notifications'
export { getPaginatedItems } from './paginated' export { getPaginatedItems } from './paginated'
export type { PageParams } from './master' export type { PageParams } from './master'
+24
View File
@@ -0,0 +1,24 @@
import { api } from '@/lib/api'
import type { NotificationPage } from '@/types/notifications'
export const notifications = {
list: (params?: { unreadOnly?: boolean; page?: number; pageSize?: number }) =>
api.get<NotificationPage>('/notifications', { params }).then(r => r.data),
unreadCount: () =>
api.get<{ count: number }>('/notifications/unread-count').then(r => r.data.count),
markRead: (id: string) =>
api.patch(`/notifications/${id}/read`).then(() => undefined),
markAllRead: () =>
api.patch('/notifications/read-all').then(() => undefined),
remove: (id: string) =>
api.delete(`/notifications/${id}`).then(() => undefined),
}
/** SignalR hub URL — the API base URL without its trailing /api. */
export const NOTIFICATION_HUB_URL =
(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5089/api').replace(/\/api\/?$/, '') +
'/hubs/notifications'
+181 -1
View File
@@ -12,6 +12,7 @@
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10", "@hugeicons/react": "^1.1.10",
"@microsoft/signalr": "^10.0.11",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"axios": "^1.19.0", "axios": "^1.19.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1763,6 +1764,28 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@microsoft/signalr": {
"version": "10.0.11",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz",
"integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@microsoft/signalr/node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -3251,6 +3274,18 @@
"win32" "win32"
] ]
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5683,6 +5718,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@@ -5923,6 +5967,16 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/fflate": { "node_modules/fflate": {
"version": "0.8.3", "version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
@@ -8426,6 +8480,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.53", "version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
@@ -9272,11 +9346,22 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -9298,6 +9383,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -9651,6 +9742,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
@@ -9971,6 +10068,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10860,6 +10963,36 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/traverse": { "node_modules/traverse": {
"version": "0.3.10", "version": "0.3.10",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.10.tgz", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.10.tgz",
@@ -11316,6 +11449,16 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -11391,6 +11534,22 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -11507,6 +11666,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": { "node_modules/wsl-utils": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10", "@hugeicons/react": "^1.1.10",
"@microsoft/signalr": "^10.0.11",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"axios": "^1.19.0", "axios": "^1.19.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+23
View File
@@ -0,0 +1,23 @@
export type NotificationSeverity = 'Info' | 'Success' | 'Warning' | 'Critical'
export interface NotificationResponse {
/** The caller's own recipient row id — use it for read / delete. */
id: string
type: string
severity: NotificationSeverity
title: string
message: string
entityType: string
entityId: string
link: string
createdAt: string
isRead: boolean
readAt: string | null
}
export interface NotificationPage {
page: number
pageSize: number
total: number
items: NotificationResponse[]
}