Introduced NotificationService for managing notification logic and recipient resolution.
- Integrated SignalR for real-time notification delivery to users.
This commit is contained in:
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");
|
||||
|
||||
@@ -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.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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user