diff --git a/TaxBaik.Web/Hubs/NotificationHub.cs b/TaxBaik.Web/Hubs/NotificationHub.cs new file mode 100644 index 0000000..0b029a6 --- /dev/null +++ b/TaxBaik.Web/Hubs/NotificationHub.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace TaxBaik.Web.Hubs; + +/// +/// Real-time notification hub for admin dashboard +/// SOLID: Single Responsibility - Only broadcasts change notifications +/// No state management - stateless broadcast pattern +/// +[Authorize] +public class NotificationHub : Hub +{ + private const string AdminGroup = "admins"; + + public override async Task OnConnectedAsync() + { + await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup); + await base.OnConnectedAsync(); + } + + /// + /// Broadcast inquiry status changed to all connected admins + /// Clients should re-fetch from API to verify + /// + public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus) + { + await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new + { + InquiryId = inquiryId, + Status = newStatus, + ChangedAt = DateTime.UtcNow + }); + } + + /// + /// Broadcast inquiry submitted (new inquiry created) + /// + public async Task NotifyInquiryCreated(int inquiryId, string name) + { + await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new + { + InquiryId = inquiryId, + Name = name, + CreatedAt = DateTime.UtcNow + }); + } + + /// + /// Broadcast client created + /// + public async Task NotifyClientCreated(int clientId, string name) + { + await Clients.Group(AdminGroup).SendAsync("ClientCreated", new + { + ClientId = clientId, + Name = name, + CreatedAt = DateTime.UtcNow + }); + } + + /// + /// Broadcast announcement published + /// + public async Task NotifyAnnouncementPublished(int announcementId, string title) + { + await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new + { + AnnouncementId = announcementId, + Title = title, + PublishedAt = DateTime.UtcNow + }); + } + + /// + /// Broadcast tax filing completed + /// + public async Task NotifyFilingCompleted(int filingId, string filingType) + { + await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new + { + FilingId = filingId, + FilingType = filingType, + CompletedAt = DateTime.UtcNow + }); + } +} diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index b492bda..5f8d62d 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -21,6 +21,9 @@ builder.Services.AddControllers(); builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); +// SignalR (Notifications only, no state management) +builder.Services.AddSignalR(); + // Razor Pages + Blazor Server 통합 builder.Services.AddRazorPages(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -66,6 +69,9 @@ builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthorization(); builder.Services.AddAuthorizationCore(); +// Notifications (SignalR) +builder.Services.AddScoped(); + // HTTP Client for API (with automatic token refresh) builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -179,6 +185,9 @@ if (!app.Environment.IsDevelopment()) app.MapControllers(); app.MapHealthChecks("/healthz"); app.MapRazorPages(); + +// SignalR Hub +app.MapHub("/taxbaik/notifications"); // AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다. // 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다. app.MapRazorComponents() diff --git a/TaxBaik.Web/Services/NotificationService.cs b/TaxBaik.Web/Services/NotificationService.cs new file mode 100644 index 0000000..90ebf75 --- /dev/null +++ b/TaxBaik.Web/Services/NotificationService.cs @@ -0,0 +1,72 @@ +namespace TaxBaik.Web.Services; + +/// +/// Notification service for real-time admin updates +/// SOLID: Single Responsibility - Event notification only +/// Uses Blazor Server's built-in SignalR for real-time communication +/// +public interface INotificationService +{ + event Func? OnInquiryStatusChanged; + event Func? OnInquiryCreated; + event Func? OnClientCreated; + event Func? OnAnnouncementPublished; + event Func? OnFilingCompleted; + + Task TriggerInquiryStatusChanged(int inquiryId, string status); + Task TriggerInquiryCreated(int inquiryId, string name); + Task TriggerClientCreated(int clientId, string name); + Task TriggerAnnouncementPublished(int announcementId, string title); + Task TriggerFilingCompleted(int filingId, string filingType); +} + +public class NotificationService : INotificationService +{ + private readonly ILogger _logger; + + public NotificationService(ILogger logger) + { + _logger = logger; + } + + public event Func? OnInquiryStatusChanged; + public event Func? OnInquiryCreated; + public event Func? OnClientCreated; + public event Func? OnAnnouncementPublished; + public event Func? OnFilingCompleted; + + public async Task TriggerInquiryStatusChanged(int inquiryId, string status) + { + _logger.LogInformation($"Inquiry {inquiryId} status changed to {status}"); + if (OnInquiryStatusChanged != null) + await OnInquiryStatusChanged(inquiryId, status); + } + + public async Task TriggerInquiryCreated(int inquiryId, string name) + { + _logger.LogInformation($"New inquiry {inquiryId} from {name}"); + if (OnInquiryCreated != null) + await OnInquiryCreated(inquiryId, name); + } + + public async Task TriggerClientCreated(int clientId, string name) + { + _logger.LogInformation($"New client {clientId}: {name}"); + if (OnClientCreated != null) + await OnClientCreated(clientId, name); + } + + public async Task TriggerAnnouncementPublished(int announcementId, string title) + { + _logger.LogInformation($"Announcement {announcementId} published: {title}"); + if (OnAnnouncementPublished != null) + await OnAnnouncementPublished(announcementId, title); + } + + public async Task TriggerFilingCompleted(int filingId, string filingType) + { + _logger.LogInformation($"Filing {filingId} ({filingType}) completed"); + if (OnFilingCompleted != null) + await OnFilingCompleted(filingId, filingType); + } +}