[CLAUDE] App+Domain+Infra+Api+FE: Notifications module end-to-end
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s

Domain:
- Notification entity + NotificationType enum (stable ints)
- Nullable RefId cho correlation (contract, user, ...)

Infrastructure:
- NotificationConfiguration: bảng Notifications, index theo (UserId, ReadAt)
- NotificationService: ghi vào DbContext, không SaveChanges (để caller quyết
  định unit-of-work — đảm bảo atomic với domain mutation)
- EF migration AddNotifications

Application:
- INotificationService (Notify + NotifyMany)
- CQRS: ListMyNotifications / GetMyUnreadCount / MarkRead / MarkAllRead

Api:
- NotificationsController: GET /api/notifications + unread-count + mark-read

Integration:
- ContractWorkflowService emit notification tới Drafter khi HĐ chuyển phase
  (skip nếu actor chính là Drafter). Title + type theo phase đích:
  DaPhatHanh → ContractPublished, TuChoi → ContractRejected, khác →
  ContractPhaseTransition.

FE:
- Both NotificationBell (admin + user) dùng /api/notifications thật
  (thay cho derived-from-inbox MVP trước đó). 30s refetch, click mark-read,
  'Đọc hết' bulk action.

Foundation sẵn cho SignalR push + email outbox sau này — chỉ cần mở rộng
NotificationService mà không đổi caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 15:24:09 +07:00
parent 6c0e20649a
commit 49c0ddc8f4
17 changed files with 1619 additions and 110 deletions

View File

@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.Reports.Services;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms;
@ -30,6 +31,7 @@ public static class DependencyInjection
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<INotificationService, NotificationService>();
// Phase 3 iteration 2 — SLA auto-approve background service
services.AddHostedService<SlaExpiryJob>();

View File

@ -5,6 +5,7 @@ using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.Persistence;
@ -25,6 +26,7 @@ public class ApplicationDbContext
public DbSet<ContractComment> ContractComments => Set<ContractComment>();
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<Notification> Notifications => Set<Notification>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class NotificationConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> e)
{
e.ToTable("Notifications");
e.HasKey(x => x.Id);
e.Property(x => x.Type).HasConversion<int>();
e.Property(x => x.Title).HasMaxLength(300).IsRequired();
e.Property(x => x.Description).HasMaxLength(1000);
e.Property(x => x.Href).HasMaxLength(500);
e.HasIndex(x => new { x.UserId, x.ReadAt });
e.HasIndex(x => x.CreatedAt);
}
}

View File

@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.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: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Href = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
ReadAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RefId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Notifications_CreatedAt",
table: "Notifications",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_ReadAt",
table: "Notifications",
columns: new[] { "UserId", "ReadAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}

View File

@ -879,6 +879,58 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Suppliers", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Href")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("ReadAt")
.HasColumnType("datetime2");
b.Property<Guid?>("RefId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("UserId", "ReadAt");
b.ToTable("Notifications", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)

View File

@ -2,15 +2,18 @@ using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.Services;
public class ContractWorkflowService(
IApplicationDbContext db,
IContractCodeGenerator codeGenerator,
IDateTime dateTime) : IContractWorkflowService
IDateTime dateTime,
INotificationService notifications) : IContractWorkflowService
{
// Map (from, to) → roles được phép chuyển. Xem docs/workflow-contract.md §5.
// Admin luôn bypass (check trong Handler trước khi gọi service).
@ -115,6 +118,31 @@ public class ContractWorkflowService(
ApprovedAt = dateTime.UtcNow,
});
// Notify the drafter (unless they are the actor or contract has no drafter)
if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
{
var title = targetPhase switch
{
ContractPhase.DaPhatHanh => $"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
ContractPhase.TuChoi => $"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
_ => $"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển sang phase mới",
};
var type = targetPhase switch
{
ContractPhase.DaPhatHanh => NotificationType.ContractPublished,
ContractPhase.TuChoi => NotificationType.ContractRejected,
_ => NotificationType.ContractPhaseTransition,
};
await notifications.NotifyAsync(
drafterId,
type,
title,
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
href: $"/contracts/{contract.Id}",
refId: contract.Id,
ct: ct);
}
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,59 @@
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.Services;
// MVP: writes directly to DbContext. Does NOT call SaveChanges — caller's unit of
// work flushes both the domain mutation and the notification atomically.
// Future: wrap with SignalR IHubContext push + Outbox for email dispatch.
public class NotificationService(IApplicationDbContext db, IDateTime clock) : INotificationService
{
public Task NotifyAsync(
Guid userId,
NotificationType type,
string title,
string? description = null,
string? href = null,
Guid? refId = null,
CancellationToken ct = default)
{
db.Notifications.Add(new Notification
{
UserId = userId,
Type = type,
Title = title,
Description = description,
Href = href,
RefId = refId,
CreatedAt = clock.UtcNow,
});
return Task.CompletedTask;
}
public Task NotifyManyAsync(
IEnumerable<Guid> userIds,
NotificationType type,
string title,
string? description = null,
string? href = null,
Guid? refId = null,
CancellationToken ct = default)
{
var now = clock.UtcNow;
foreach (var userId in userIds.Distinct())
{
db.Notifications.Add(new Notification
{
UserId = userId,
Type = type,
Title = title,
Description = description,
Href = href,
RefId = refId,
CreatedAt = now,
});
}
return Task.CompletedTask;
}
}