feat(backend): Add SignalR real-time notifications for Sprint events - Sprint 2 Story 3 Task 5

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 <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-05 00:35:33 +01:00
parent 252674b508
commit 96fed691ab
4 changed files with 265 additions and 1 deletions

View File

@@ -0,0 +1,139 @@
using MediatR;
using ColaFlow.API.Services;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using Microsoft.Extensions.Logging;
namespace ColaFlow.API.EventHandlers;
/// <summary>
/// Handles Sprint domain events and sends SignalR notifications
/// </summary>
public class SprintEventHandlers :
INotificationHandler<SprintCreatedEvent>,
INotificationHandler<SprintUpdatedEvent>,
INotificationHandler<SprintStartedEvent>,
INotificationHandler<SprintCompletedEvent>,
INotificationHandler<SprintDeletedEvent>
{
private readonly IRealtimeNotificationService _notificationService;
private readonly ILogger<SprintEventHandlers> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
public SprintEventHandlers(
IRealtimeNotificationService notificationService,
ILogger<SprintEventHandlers> 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
}
}

View File

@@ -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");

View File

@@ -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}";

View File

@@ -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;
/// </summary>
public sealed class UpdateSprintCommandHandler(
IApplicationDbContext context,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IMediator mediator)
: IRequestHandler<UpdateSprintCommand, Unit>
{
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<Unit> 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;
}
}