Introduced NotificationService for managing notification logic and recipient resolution.

- Integrated SignalR for real-time notification delivery to users.
This commit is contained in:
2026-10-20 16:51:01 +05:30
parent a0df2a1bbf
commit 6b9f27573b
8 changed files with 6649 additions and 84 deletions
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");
}
}
}
@@ -1278,6 +1278,93 @@ namespace Distribution.Migrations
b.ToTable("LoadingSheetOrders");
});
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 =>
{
b.Property<Guid>("Id")
@@ -2528,7 +2615,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("30f658ac-2c92-4cd8-ad47-10b0def2699c"),
Id = new Guid("1ce4ca63-cda9-4e52-a33a-d9f817b3af42"),
IsActive = true
},
new
@@ -2536,7 +2623,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("42ab2c52-eaf8-4a17-b67a-ccd66569e56a"),
Id = new Guid("fd551395-791e-4ec0-8acc-90fee0fdf0e4"),
IsActive = true
},
new
@@ -2544,7 +2631,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("8afec825-27b3-4153-ad06-f8bcdbf93da8"),
Id = new Guid("eed0b3b9-c142-4d85-a03c-a4d5047ed839"),
IsActive = true
},
new
@@ -2552,7 +2639,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000004"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("4b427620-e99d-4068-a416-c3788d5bd9b5"),
Id = new Guid("fe36f257-7b58-4afe-a950-33e54623a3b0"),
IsActive = true
},
new
@@ -2560,7 +2647,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000005"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("361f44ef-1cd1-48a9-be4d-4c068aacef1e"),
Id = new Guid("b7fd5318-8487-4b86-9651-cdafbcd20776"),
IsActive = true
},
new
@@ -2568,7 +2655,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000006"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("11fc03ae-92ee-4336-ae57-29aac731ad51"),
Id = new Guid("8f84fb31-063f-49b7-bec8-3a65f1bbc66e"),
IsActive = true
},
new
@@ -2576,7 +2663,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-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),
Id = new Guid("0b1f2b73-ef60-4634-864f-22a6c0cd1373"),
Id = new Guid("8f181d4a-4eab-4b3a-88bf-a228f544969a"),
IsActive = true
},
new
@@ -2584,7 +2671,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000008"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("2380abf3-2bcd-4a9f-b447-5f1685df2fc8"),
Id = new Guid("fd54d83b-b79c-4c47-bc0d-7c728cab064f"),
IsActive = true
},
new
@@ -2592,7 +2679,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000009"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("240e4d6a-8306-40ba-aaec-8e05cd0347db"),
Id = new Guid("4306aada-781c-4618-b10c-a937b4760892"),
IsActive = true
},
new
@@ -2600,7 +2687,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000010"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("cfde0aa6-e859-4f9c-8291-b0d886fcd4c9"),
Id = new Guid("4b74304c-e3a0-4366-9eca-91cb71ba4553"),
IsActive = true
},
new
@@ -2608,7 +2695,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000011"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("dae772c8-26ec-4b5c-8d7a-f3196510bf2c"),
Id = new Guid("ef3b60c8-5698-4757-93c7-ce54759f3c7b"),
IsActive = true
},
new
@@ -2616,7 +2703,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000012"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("3512c0a4-a5ae-4002-a779-7d3cc600ab61"),
Id = new Guid("90761b9f-0244-43da-bec6-c2e1e9c9103e"),
IsActive = true
},
new
@@ -2624,7 +2711,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000013"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("f9e2a348-159e-47a2-b7be-a60f0254873f"),
Id = new Guid("6bd386df-32b4-434b-8d16-7f1555f92848"),
IsActive = true
},
new
@@ -2632,7 +2719,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000014"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("c8b4c181-5b08-45d2-818b-74bceeab78a8"),
Id = new Guid("5e64183c-9fe3-48f1-a236-91df9de1378a"),
IsActive = true
},
new
@@ -2640,7 +2727,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000015"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("8fb4ff4c-c5bd-4afd-a08e-02861d63cb7b"),
Id = new Guid("b0e3f065-ccbe-4a6a-9c29-29986868e587"),
IsActive = true
},
new
@@ -2648,7 +2735,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000016"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("edb3b711-d623-439d-811e-afaef8d0680d"),
Id = new Guid("cd191ac2-c1a5-454f-80a6-ffa5770a3699"),
IsActive = true
},
new
@@ -2656,7 +2743,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000017"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("f33e4c62-d69f-4a91-8b62-acbd5351ea2a"),
Id = new Guid("dba4d3dd-60a1-4445-a327-22026ca35f77"),
IsActive = true
},
new
@@ -2664,7 +2751,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000018"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("c7c9d16c-e343-43e9-9e27-f38f903a5d00"),
Id = new Guid("c6a75c84-7992-46b5-a1f9-cdf1bf3e77a4"),
IsActive = true
},
new
@@ -2672,7 +2759,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000019"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("f5f4760a-8a45-46ca-8093-743c95b0313f"),
Id = new Guid("a5d1be1b-2fe9-49f9-9256-9da61a7589ae"),
IsActive = true
},
new
@@ -2680,7 +2767,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000020"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("c5434801-146a-4fd9-9c29-a1145e7f7f00"),
Id = new Guid("bdbeb46e-3a54-4ae2-90a6-e9b4450b83d3"),
IsActive = true
},
new
@@ -2688,7 +2775,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000021"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("6c085135-78a5-4e65-a380-ee090b07e2bb"),
Id = new Guid("eeab7811-ff26-4e4f-9813-6c10cd6c6504"),
IsActive = true
},
new
@@ -2696,7 +2783,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000022"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("bbde2785-a3df-4f81-bad1-0bc5afe5de57"),
Id = new Guid("c31fc81f-3e25-422f-81dd-dc6621aad5a4"),
IsActive = true
},
new
@@ -2704,7 +2791,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000023"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("4421a0fb-bdd4-45a9-8dc0-d9b70f775e97"),
Id = new Guid("11d9afa6-fecd-494a-9264-cd57f7edc552"),
IsActive = true
},
new
@@ -2712,7 +2799,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000024"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("dba9dcb4-3b94-4ddc-89f3-552cfc109478"),
Id = new Guid("3e704029-4b4b-40b0-b202-97fbf266ec31"),
IsActive = true
},
new
@@ -2720,7 +2807,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000025"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("d8f74160-c150-4d9c-aba6-e45e628c684e"),
Id = new Guid("fcaea017-aae4-4a42-92d2-da2b994169d9"),
IsActive = true
},
new
@@ -2728,7 +2815,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000026"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("495fb68b-9f01-4538-9561-fefce0d39558"),
Id = new Guid("a58856db-d72b-4d06-a56b-b34861e94551"),
IsActive = true
},
new
@@ -2736,7 +2823,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000027"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("3bdece9b-a798-4223-a23c-9c9dd10d042d"),
Id = new Guid("2795cbf1-de83-48d2-b955-818e30d31659"),
IsActive = true
},
new
@@ -2744,7 +2831,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000028"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("6fa616eb-99fe-46b9-8305-908765d037fd"),
Id = new Guid("cd60d268-87bf-44a0-9414-cfbd7dc8b30c"),
IsActive = true
},
new
@@ -2752,7 +2839,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000029"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("7a07148a-1327-4e97-befc-b2045eacad21"),
Id = new Guid("7b435bfb-7fd4-4f26-a746-df77fbb69b64"),
IsActive = true
},
new
@@ -2760,7 +2847,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000030"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("852e86d7-aa57-4a77-81c0-054f3fb4d7f7"),
Id = new Guid("519222bb-2021-42ec-a888-ffbbef3ebebf"),
IsActive = true
},
new
@@ -2768,7 +2855,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000031"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("3b767929-c973-41fb-a340-4ee5feb65619"),
Id = new Guid("e629ea0e-70e6-4ff5-8f5e-dffbcac162ba"),
IsActive = true
},
new
@@ -2776,7 +2863,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000032"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("63619bbd-0352-41bd-9e1c-a3f7908fb5d2"),
Id = new Guid("34d421af-e9cb-4eb5-ab13-afd5d26749f9"),
IsActive = true
},
new
@@ -2784,7 +2871,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000033"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("f6b4c8bf-a9b3-4179-9f57-a8584678c0d1"),
Id = new Guid("9b2af108-27a1-4cfc-a0d5-ad6a54ef9804"),
IsActive = true
},
new
@@ -2792,7 +2879,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000034"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("bb47b8ca-f7cf-40fe-8945-fc532a439b53"),
Id = new Guid("431ed966-2f97-45bb-a28e-330ebfbd90ee"),
IsActive = true
},
new
@@ -2800,7 +2887,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000035"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("52126271-ae3b-4b58-a9d2-50e2dd894087"),
Id = new Guid("11ed5a9f-a14f-4e7a-ade1-d9b39e30c1e5"),
IsActive = true
},
new
@@ -2808,7 +2895,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000036"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("981e3358-85bb-47fe-a1f5-3a3a02cf47b1"),
Id = new Guid("ec51a647-7a5a-4bab-a44c-aadced5448ea"),
IsActive = true
},
new
@@ -2816,7 +2903,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000037"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("749408da-fcee-458d-9144-8d8dee928ba0"),
Id = new Guid("87cd6a37-e18a-4c43-aeeb-ab6d7f866e6b"),
IsActive = true
},
new
@@ -2824,7 +2911,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000038"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("c157857c-fb0e-41fd-a414-c0f57a44b120"),
Id = new Guid("ca2ad12a-65d0-41b7-9ae8-9ac8df117d01"),
IsActive = true
},
new
@@ -2832,7 +2919,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000039"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("3217cdbc-76c9-4505-b9f3-23de91a6212b"),
Id = new Guid("817d6c94-11c3-4733-9fe5-c19f56316200"),
IsActive = true
},
new
@@ -2840,7 +2927,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000040"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("63526c52-18ba-40e0-9889-3bd3b7fc8161"),
Id = new Guid("5b7f95c1-ddb3-4ce8-b62f-11f3c37c44b2"),
IsActive = true
},
new
@@ -2848,7 +2935,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000041"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("260cf2fa-1ca6-4cd5-92ae-8c479a80e2f0"),
Id = new Guid("a84f7909-8f45-4ca3-a10f-2fc17680dfc0"),
IsActive = true
},
new
@@ -2856,7 +2943,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000042"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("130ab09c-8a36-4705-8235-9d00f75dd684"),
Id = new Guid("2af26741-1110-4853-82aa-70639060c087"),
IsActive = true
},
new
@@ -2864,7 +2951,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000043"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("41794774-e919-4f4e-a42a-5ae408d3407f"),
Id = new Guid("f2267efb-00c3-49f2-8f5d-c9cabd6cc1ec"),
IsActive = true
},
new
@@ -2872,7 +2959,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000044"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("2ff23d04-d1f6-4aa3-a48e-1c82bb9ad12a"),
Id = new Guid("6026dc21-65d6-4754-8e08-7a8588b41020"),
IsActive = true
},
new
@@ -2880,7 +2967,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000045"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("d36c81c0-8e9b-41ea-8e09-464a327a16d2"),
Id = new Guid("769408c0-fa97-4234-9ea8-a4e5a1a847be"),
IsActive = true
},
new
@@ -2888,7 +2975,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000046"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("6c51dfc1-63e0-46a6-b2bb-27c21c708b51"),
Id = new Guid("bace0a50-82ca-4d44-88f2-d0ecef753b0a"),
IsActive = true
},
new
@@ -2896,7 +2983,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000047"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("076333d5-b1f3-4b89-86fd-5a61a4451c88"),
Id = new Guid("9043c1e0-cf7b-4ac3-affd-52b0706d1753"),
IsActive = true
},
new
@@ -2904,7 +2991,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000048"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("7e834f3e-1424-4aaa-816e-d62da6df672b"),
Id = new Guid("b4572222-2bcc-4d18-aa82-d5fdb2a3f4ae"),
IsActive = true
},
new
@@ -2912,7 +2999,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000049"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("699fa06b-a7ff-4dbd-98a7-a29c2c361665"),
Id = new Guid("2f6ab72b-a3f2-4a47-8681-0ee575c9998f"),
IsActive = true
},
new
@@ -2920,7 +3007,7 @@ namespace Distribution.Migrations
FK_RoleId = new Guid("10000000-0000-0000-0000-000000000007"),
FK_PermissionId = new Guid("20000000-0000-0000-0000-000000000050"),
CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("00df5735-54c6-4cdf-8e2c-a64cd90ede15"),
Id = new Guid("395971d4-1369-45d4-8b54-28c1a33b7e0c"),
IsActive = true
});
});
@@ -4967,6 +5054,25 @@ namespace Distribution.Migrations
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 =>
{
b.HasOne("Distribution.Models.BankAccount", "BankAccount")
@@ -5659,6 +5765,11 @@ namespace Distribution.Migrations
b.Navigation("Orders");
});
modelBuilder.Entity("Distribution.Models.Notification", b =>
{
b.Navigation("Recipients");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.Navigation("Items");
+33
View File
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace Distribution.Models
{
public enum NotificationSeverity { Info, Success, Warning, Critical }
/// <summary>One row per event. Who it was delivered to lives in <see cref="NotificationRecipient"/>.</summary>
public class Notification : Template
{
[MaxLength(60)]
public string Type { get; set; } = string.Empty;
public NotificationSeverity Severity { get; set; }
[MaxLength(150)]
public string Title { get; set; } = string.Empty;
[MaxLength(500)]
public string Message { get; set; } = string.Empty;
[MaxLength(50)]
public string EntityType { get; set; } = string.Empty;
public Guid EntityId { get; set; }
[MaxLength(200)]
public string Link { get; set; } = string.Empty;
public Guid? CreatedByUserId { get; set; }
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
}
}
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>One row per user per notification, so read state is per user.</summary>
public class NotificationRecipient : Template
{
public Guid FK_NotificationId { get; set; }
[ForeignKey("FK_NotificationId")]
public Notification Notification { get; set; }
public Guid FK_UserId { get; set; }
[ForeignKey("FK_UserId")]
public User User { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
}
}
+11
View File
@@ -12,6 +12,7 @@ using Distribution.Features.ReceivedCheque;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Notifications;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Invoice;
@@ -141,6 +142,15 @@ builder.Services.AddScoped<ReceivedChequeService>();
builder.Services.AddScoped<SupplierPaymentService>();
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.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@@ -192,5 +202,6 @@ app.UseMiddleware<AuthMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<NotificationHub>(NotificationHub.Path);
app.Run();
+297 -34
View File
@@ -1,67 +1,330 @@
# 21 — Notifications & Alerts
**Status**: Not Started
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
**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, 15 — Procurement, 18 — Returns & Claims, 19 — Finance & Accounting
---
## Goal
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
produce.
Real-time **in-app** notifications (bell + toast) triggered by events other modules already produce
([mainPlan.md §15](./mainPlan.md#15-notifications--alerts)). Email / SMS / push are out of scope for v1.
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
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
[12_store_management.md](./12_store_management.md), route deviation logic in
[09_fraud_controls.md](./09_fraud_controls.md)).
Every notification is **in-app only** (bell + toast + `/notifications`), pushed live over SignalR.
| # | When this happens | Where in the app | Notification the user sees | Who receives it | Who does **not** | Level | Click opens |
|---|---|---|---|---|---|---|---|
| 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
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
`ReadAt` (nullable).
```
Service action ──► uow.CommitAsync() (business transaction, unchanged)
│
▼ 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) |
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
| `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)) |
| `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. |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
| First batch of triggers | The seven Phase 1 triggers below. Cheque triggers wait until finance permissions exist in the seed |
| Channels | In-app only (SignalR). Email / SMS / push deferred |
| Per-type config table + admin screen | **Not in v1.** Every type is always enabled; add `NotificationModuleConfig` when someone needs to mute a type |
| Scheduled alerts (Phase 1b) | Second pass. Only the retention cleanup job ships in v1 |
| CORS / instances | Existing `Cors:AllowedOrigins` + `AllowCredentials()` already suit SignalR. Single API instance, no Redis backplane |
| 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
endpoints are inbox read/list.
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
transaction that triggered them — fire-and-forget, logged separately.
**Notification** — one row per event
| Field | Type | Notes |
|---|---|---|
| `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
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
significantly changes the infrastructure needed (third-party provider integration, see
[22_integration_api.md](./22_integration_api.md)).
Resolved — see **Decisions**. Remaining for later:
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.