[CLAUDE] App+Domain+Infra+Api+FE: Notifications module end-to-end
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m43s
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:
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user