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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,21 +12,24 @@ export default function DashboardLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<NotificationProvider>
|
||||||
<TopLoader />
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<TopLoader />
|
||||||
<SidebarInset>
|
<AppSidebar />
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
<SidebarInset>
|
||||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
<div className="ml-auto">
|
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||||
<ThemeToggle />
|
<div className="ml-auto flex items-center gap-1">
|
||||||
</div>
|
<NotificationBell />
|
||||||
</header>
|
<ThemeToggle />
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
</div>
|
||||||
{children}
|
</header>
|
||||||
</main>
|
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||||
</SidebarInset>
|
{children}
|
||||||
<BottomNav />
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarInset>
|
||||||
|
<BottomNav />
|
||||||
|
</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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
Generated
+181
-1
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user