[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

@ -0,0 +1,11 @@
namespace SolutionErp.Application.Notifications.Dtos;
public record NotificationDto(
Guid Id,
int Type,
string Title,
string? Description,
string? Href,
Guid? RefId,
DateTime CreatedAt,
DateTime? ReadAt);

View File

@ -0,0 +1,27 @@
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Application.Notifications;
// Abstraction for emitting notifications from domain handlers.
// Implementation lives in Infrastructure and writes to DbContext (eventual SignalR
// push + email will layer on the same method without changing callers).
public interface INotificationService
{
Task NotifyAsync(
Guid userId,
NotificationType type,
string title,
string? description = null,
string? href = null,
Guid? refId = null,
CancellationToken ct = default);
Task NotifyManyAsync(
IEnumerable<Guid> userIds,
NotificationType type,
string title,
string? description = null,
string? href = null,
Guid? refId = null,
CancellationToken ct = default);
}

View File

@ -0,0 +1,100 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications.Dtos;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Application.Notifications;
// ========== LIST current-user's notifications ==========
public record ListMyNotificationsQuery(bool UnreadOnly, int Limit) : IRequest<IReadOnlyList<NotificationDto>>;
public class ListMyNotificationsQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<ListMyNotificationsQuery, IReadOnlyList<NotificationDto>>
{
public async Task<IReadOnlyList<NotificationDto>> Handle(ListMyNotificationsQuery request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var take = request.Limit is > 0 and <= 200 ? request.Limit : 50;
var q = db.Notifications.AsNoTracking().Where(n => n.UserId == userId);
if (request.UnreadOnly) q = q.Where(n => n.ReadAt == null);
return await q
.OrderByDescending(n => n.CreatedAt)
.Take(take)
.Select(n => new NotificationDto(
n.Id,
(int)n.Type,
n.Title,
n.Description,
n.Href,
n.RefId,
n.CreatedAt,
n.ReadAt))
.ToListAsync(ct);
}
}
// ========== UNREAD count ==========
public record GetMyUnreadCountQuery : IRequest<int>;
public class GetMyUnreadCountQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<GetMyUnreadCountQuery, int>
{
public async Task<int> Handle(GetMyUnreadCountQuery request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return await db.Notifications.CountAsync(n => n.UserId == userId && n.ReadAt == null, ct);
}
}
// ========== MARK as read ==========
public record MarkNotificationReadCommand(Guid Id) : IRequest;
public class MarkNotificationReadCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IDateTime clock) : IRequestHandler<MarkNotificationReadCommand>
{
public async Task Handle(MarkNotificationReadCommand request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var entity = await db.Notifications.FirstOrDefaultAsync(n => n.Id == request.Id && n.UserId == userId, ct)
?? throw new NotFoundException("Notification", request.Id);
if (entity.ReadAt == null)
{
entity.ReadAt = clock.UtcNow;
await db.SaveChangesAsync(ct);
}
}
}
// ========== MARK all as read ==========
public record MarkAllNotificationsReadCommand : IRequest<int>;
public class MarkAllNotificationsReadCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IDateTime clock) : IRequestHandler<MarkAllNotificationsReadCommand, int>
{
public async Task<int> Handle(MarkAllNotificationsReadCommand request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var now = clock.UtcNow;
var unread = await db.Notifications
.Where(n => n.UserId == userId && n.ReadAt == null)
.ToListAsync(ct);
foreach (var n in unread) n.ReadAt = now;
if (unread.Count > 0) await db.SaveChangesAsync(ct);
return unread.Count;
}
}