From 96fed691ab9150ce56229380a7d1213048023ce7 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 5 Nov 2025 00:35:33 +0100 Subject: [PATCH] feat(backend): Add SignalR real-time notifications for Sprint events - Sprint 2 Story 3 Task 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive SignalR notifications for Sprint lifecycle events. Features: - Extended IRealtimeNotificationService with 5 Sprint notification methods - Implemented Sprint notification service methods in RealtimeNotificationService - Created SprintEventHandlers to handle all 5 Sprint domain events - Updated UpdateSprintCommandHandler to publish SprintUpdatedEvent - SignalR events broadcast to both project and tenant groups Sprint Events Implemented: 1. SprintCreated - New sprint created 2. SprintUpdated - Sprint details modified 3. SprintStarted - Sprint transitioned to Active status 4. SprintCompleted - Sprint transitioned to Completed status 5. SprintDeleted - Sprint removed Technical Details: - Event handlers catch and log errors (fire-and-forget pattern) - Notifications include SprintId, SprintName, ProjectId, and Timestamp - Multi-tenant isolation via tenant groups - Project-level targeting via project groups Frontend Integration: - Frontend can listen to 'SprintCreated', 'SprintUpdated', 'SprintStarted', 'SprintCompleted', 'SprintDeleted' events - Real-time UI updates for sprint changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../EventHandlers/SprintEventHandlers.cs | 139 ++++++++++++++++++ .../Services/IRealtimeNotificationService.cs | 7 + .../Services/RealtimeNotificationService.cs | 112 ++++++++++++++ .../UpdateSprintCommandHandler.cs | 8 +- 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 colaflow-api/src/ColaFlow.API/EventHandlers/SprintEventHandlers.cs diff --git a/colaflow-api/src/ColaFlow.API/EventHandlers/SprintEventHandlers.cs b/colaflow-api/src/ColaFlow.API/EventHandlers/SprintEventHandlers.cs new file mode 100644 index 0000000..5654249 --- /dev/null +++ b/colaflow-api/src/ColaFlow.API/EventHandlers/SprintEventHandlers.cs @@ -0,0 +1,139 @@ +using MediatR; +using ColaFlow.API.Services; +using ColaFlow.Modules.ProjectManagement.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.API.EventHandlers; + +/// +/// Handles Sprint domain events and sends SignalR notifications +/// +public class SprintEventHandlers : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler +{ + private readonly IRealtimeNotificationService _notificationService; + private readonly ILogger _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + + public SprintEventHandlers( + IRealtimeNotificationService notificationService, + ILogger logger, + IHttpContextAccessor httpContextAccessor) + { + _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + } + + public async Task Handle(SprintCreatedEvent notification, CancellationToken cancellationToken) + { + try + { + var tenantId = GetCurrentTenantId(); + await _notificationService.NotifySprintCreated( + tenantId, + notification.ProjectId, + notification.SprintId, + notification.SprintName + ); + _logger.LogInformation("Sprint created notification sent: {SprintId}", notification.SprintId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Sprint created notification: {SprintId}", notification.SprintId); + } + } + + public async Task Handle(SprintUpdatedEvent notification, CancellationToken cancellationToken) + { + try + { + var tenantId = GetCurrentTenantId(); + await _notificationService.NotifySprintUpdated( + tenantId, + notification.ProjectId, + notification.SprintId, + notification.SprintName + ); + _logger.LogInformation("Sprint updated notification sent: {SprintId}", notification.SprintId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Sprint updated notification: {SprintId}", notification.SprintId); + } + } + + public async Task Handle(SprintStartedEvent notification, CancellationToken cancellationToken) + { + try + { + var tenantId = GetCurrentTenantId(); + await _notificationService.NotifySprintStarted( + tenantId, + notification.ProjectId, + notification.SprintId, + notification.SprintName + ); + _logger.LogInformation("Sprint started notification sent: {SprintId}", notification.SprintId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Sprint started notification: {SprintId}", notification.SprintId); + } + } + + public async Task Handle(SprintCompletedEvent notification, CancellationToken cancellationToken) + { + try + { + var tenantId = GetCurrentTenantId(); + await _notificationService.NotifySprintCompleted( + tenantId, + notification.ProjectId, + notification.SprintId, + notification.SprintName + ); + _logger.LogInformation("Sprint completed notification sent: {SprintId}", notification.SprintId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Sprint completed notification: {SprintId}", notification.SprintId); + } + } + + public async Task Handle(SprintDeletedEvent notification, CancellationToken cancellationToken) + { + try + { + var tenantId = GetCurrentTenantId(); + await _notificationService.NotifySprintDeleted( + tenantId, + notification.ProjectId, + notification.SprintId, + notification.SprintName + ); + _logger.LogInformation("Sprint deleted notification sent: {SprintId}", notification.SprintId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Sprint deleted notification: {SprintId}", notification.SprintId); + } + } + + private Guid GetCurrentTenantId() + { + var tenantIdClaim = _httpContextAccessor?.HttpContext?.User + .FindFirst("tenant_id")?.Value; + + if (Guid.TryParse(tenantIdClaim, out var tenantId) && tenantId != Guid.Empty) + { + return tenantId; + } + + return Guid.Empty; // Default for non-HTTP contexts + } +} diff --git a/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs b/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs index c016422..b41f24d 100644 --- a/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs +++ b/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs @@ -30,6 +30,13 @@ public interface IRealtimeNotificationService Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId); Task NotifyIssueStatusChanged(Guid tenantId, Guid projectId, Guid issueId, string oldStatus, string newStatus); + // Sprint notifications + Task NotifySprintCreated(Guid tenantId, Guid projectId, Guid sprintId, string sprintName); + Task NotifySprintUpdated(Guid tenantId, Guid projectId, Guid sprintId, string sprintName); + Task NotifySprintStarted(Guid tenantId, Guid projectId, Guid sprintId, string sprintName); + Task NotifySprintCompleted(Guid tenantId, Guid projectId, Guid sprintId, string sprintName); + Task NotifySprintDeleted(Guid tenantId, Guid projectId, Guid sprintId, string sprintName); + // User-level notifications Task NotifyUser(Guid userId, string message, string type = "info"); Task NotifyUsersInTenant(Guid tenantId, string message, string type = "info"); diff --git a/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs b/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs index 9a72106..0b652bf 100644 --- a/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs +++ b/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs @@ -202,6 +202,118 @@ public class RealtimeNotificationService : IRealtimeNotificationService }); } + // Sprint notifications + public async Task NotifySprintCreated(Guid tenantId, Guid projectId, Guid sprintId, string sprintName) + { + var projectGroupName = $"project-{projectId}"; + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying sprint {SprintId} created in project {ProjectId}", sprintId, projectId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCreated", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCreated", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + } + + public async Task NotifySprintUpdated(Guid tenantId, Guid projectId, Guid sprintId, string sprintName) + { + var projectGroupName = $"project-{projectId}"; + + _logger.LogInformation("Notifying sprint {SprintId} updated", sprintId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintUpdated", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + } + + public async Task NotifySprintStarted(Guid tenantId, Guid projectId, Guid sprintId, string sprintName) + { + var projectGroupName = $"project-{projectId}"; + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying sprint {SprintId} started", sprintId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintStarted", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintStarted", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + } + + public async Task NotifySprintCompleted(Guid tenantId, Guid projectId, Guid sprintId, string sprintName) + { + var projectGroupName = $"project-{projectId}"; + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying sprint {SprintId} completed", sprintId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCompleted", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCompleted", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + } + + public async Task NotifySprintDeleted(Guid tenantId, Guid projectId, Guid sprintId, string sprintName) + { + var projectGroupName = $"project-{projectId}"; + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying sprint {SprintId} deleted", sprintId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintDeleted", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintDeleted", new + { + SprintId = sprintId, + SprintName = sprintName, + ProjectId = projectId, + Timestamp = DateTime.UtcNow + }); + } + public async Task NotifyUser(Guid userId, string message, string type = "info") { var userConnectionId = $"user-{userId}"; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateSprint/UpdateSprintCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateSprint/UpdateSprintCommandHandler.cs index a5bf17a..cd7e44e 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateSprint/UpdateSprintCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateSprint/UpdateSprintCommandHandler.cs @@ -4,6 +4,7 @@ using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces; using ColaFlow.Modules.ProjectManagement.Domain.Repositories; using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; using ColaFlow.Modules.ProjectManagement.Domain.Exceptions; +using ColaFlow.Modules.ProjectManagement.Domain.Events; namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint; @@ -12,11 +13,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint; /// public sealed class UpdateSprintCommandHandler( IApplicationDbContext context, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IMediator mediator) : IRequestHandler { private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context)); private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); + private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); public async Task Handle(UpdateSprintCommand request, CancellationToken cancellationToken) { @@ -39,6 +42,9 @@ public sealed class UpdateSprintCommandHandler( // Save changes await _unitOfWork.SaveChangesAsync(cancellationToken); + // Publish domain event + await _mediator.Publish(new SprintUpdatedEvent(sprint.Id.Value, sprint.Name, sprint.ProjectId.Value), cancellationToken); + return Unit.Value; } }