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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@ public interface IRealtimeNotificationService
|
|||||||
Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId);
|
Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId);
|
||||||
Task NotifyIssueStatusChanged(Guid tenantId, Guid projectId, Guid issueId, string oldStatus, string newStatus);
|
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
|
// User-level notifications
|
||||||
Task NotifyUser(Guid userId, string message, string type = "info");
|
Task NotifyUser(Guid userId, string message, string type = "info");
|
||||||
Task NotifyUsersInTenant(Guid tenantId, string message, string type = "info");
|
Task NotifyUsersInTenant(Guid tenantId, string message, string type = "info");
|
||||||
|
|||||||
@@ -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")
|
public async Task NotifyUser(Guid userId, string message, string type = "info")
|
||||||
{
|
{
|
||||||
var userConnectionId = $"user-{userId}";
|
var userConnectionId = $"user-{userId}";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
|||||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Events;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
||||||
|
|
||||||
@@ -12,11 +13,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateSprint;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateSprintCommandHandler(
|
public sealed class UpdateSprintCommandHandler(
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
IUnitOfWork unitOfWork)
|
IUnitOfWork unitOfWork,
|
||||||
|
IMediator mediator)
|
||||||
: IRequestHandler<UpdateSprintCommand, Unit>
|
: IRequestHandler<UpdateSprintCommand, Unit>
|
||||||
{
|
{
|
||||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
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)
|
public async Task<Unit> Handle(UpdateSprintCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -39,6 +42,9 @@ public sealed class UpdateSprintCommandHandler(
|
|||||||
// Save changes
|
// Save changes
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Publish domain event
|
||||||
|
await _mediator.Publish(new SprintUpdatedEvent(sprint.Id.Value, sprint.Name, sprint.ProjectId.Value), cancellationToken);
|
||||||
|
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user