feat(signalr): Add real-time notifications for Epic/Story/Task operations

Extends SignalR notification system to cover all ProjectManagement CRUD operations:

Domain Events Created:
- EpicUpdatedEvent, EpicDeletedEvent
- StoryCreatedEvent, StoryUpdatedEvent, StoryDeletedEvent
- TaskCreatedEvent, TaskUpdatedEvent, TaskDeletedEvent, TaskAssignedEvent

Event Handlers Added (10 handlers):
- EpicCreatedEventHandler, EpicUpdatedEventHandler, EpicDeletedEventHandler
- StoryCreatedEventHandler, StoryUpdatedEventHandler, StoryDeletedEventHandler
- TaskCreatedEventHandler, TaskUpdatedEventHandler, TaskDeletedEventHandler
- TaskAssignedEventHandler

Infrastructure Extensions:
- Extended IProjectNotificationService with Epic/Story/Task methods
- Extended IRealtimeNotificationService with Epic/Story/Task methods
- Extended RealtimeNotificationService with implementations
- Extended ProjectNotificationServiceAdapter for delegation

Domain Changes:
- Updated EpicCreatedEvent to include TenantId (consistency with other events)
- Added Epic/Story/Task CRUD methods to Project aggregate root
- All operations raise appropriate domain events

Broadcasting Strategy:
- Created events: Broadcast to both project-{projectId} and tenant-{tenantId} groups
- Updated events: Broadcast to project-{projectId} group only
- Deleted events: Broadcast to project-{projectId} group only
- Assigned events: Broadcast to project-{projectId} group with assignment details

Test Results:
- All 192 domain tests passing
- Domain and Application layers compile successfully
- Event handlers auto-registered by MediatR

Files Changed:
- 9 new domain event files
- 10 new event handler files
- 3 service interfaces extended
- 2 service implementations extended
- 1 aggregate updated with event raising logic
- 1 test file updated for new event signature

Status: Complete real-time collaboration for ProjectManagement module

🤖 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-04 20:56:08 +01:00
parent ec70455c7f
commit b53521775c
26 changed files with 896 additions and 11 deletions

View File

@@ -8,6 +8,22 @@ public interface IRealtimeNotificationService
Task NotifyProjectArchived(Guid tenantId, Guid projectId);
Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data);
// Epic notifications
Task NotifyEpicCreated(Guid tenantId, Guid projectId, Guid epicId, object epic);
Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic);
Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId);
// Story notifications
Task NotifyStoryCreated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story);
Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story);
Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId);
// Task notifications
Task NotifyTaskCreated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task);
Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task);
Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId);
Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId);
// Issue notifications
Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue);
Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue);

View File

@@ -15,6 +15,7 @@ public class ProjectNotificationServiceAdapter : IProjectNotificationService
_realtimeService = realtimeService;
}
// Project notifications
public Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project)
{
return _realtimeService.NotifyProjectCreated(tenantId, projectId, project);
@@ -29,4 +30,57 @@ public class ProjectNotificationServiceAdapter : IProjectNotificationService
{
return _realtimeService.NotifyProjectArchived(tenantId, projectId);
}
// Epic notifications
public Task NotifyEpicCreated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
return _realtimeService.NotifyEpicCreated(tenantId, projectId, epicId, epic);
}
public Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
return _realtimeService.NotifyEpicUpdated(tenantId, projectId, epicId, epic);
}
public Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId)
{
return _realtimeService.NotifyEpicDeleted(tenantId, projectId, epicId);
}
// Story notifications
public Task NotifyStoryCreated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
return _realtimeService.NotifyStoryCreated(tenantId, projectId, epicId, storyId, story);
}
public Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
return _realtimeService.NotifyStoryUpdated(tenantId, projectId, epicId, storyId, story);
}
public Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId)
{
return _realtimeService.NotifyStoryDeleted(tenantId, projectId, epicId, storyId);
}
// Task notifications
public Task NotifyTaskCreated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
return _realtimeService.NotifyTaskCreated(tenantId, projectId, storyId, taskId, task);
}
public Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
return _realtimeService.NotifyTaskUpdated(tenantId, projectId, storyId, taskId, task);
}
public Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId)
{
return _realtimeService.NotifyTaskDeleted(tenantId, projectId, storyId, taskId);
}
public Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId)
{
return _realtimeService.NotifyTaskAssigned(tenantId, projectId, taskId, assigneeId);
}
}

View File

@@ -59,6 +59,110 @@ public class RealtimeNotificationService : IRealtimeNotificationService
await _projectHubContext.Clients.Group(groupName).SendAsync("ProjectUpdated", data);
}
// Epic notifications
public async Task NotifyEpicCreated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying epic {EpicId} created in project {ProjectId}", epicId, projectId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicCreated", epic);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("EpicCreated", epic);
}
public async Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying epic {EpicId} updated", epicId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicUpdated", epic);
}
public async Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying epic {EpicId} deleted", epicId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicDeleted", new { EpicId = epicId });
}
// Story notifications
public async Task NotifyStoryCreated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying story {StoryId} created in epic {EpicId}", storyId, epicId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryCreated", story);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("StoryCreated", story);
}
public async Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying story {StoryId} updated", storyId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryUpdated", story);
}
public async Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying story {StoryId} deleted", storyId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryDeleted", new { StoryId = storyId });
}
// Task notifications
public async Task NotifyTaskCreated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying task {TaskId} created in story {StoryId}", taskId, storyId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskCreated", task);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("TaskCreated", task);
}
public async Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying task {TaskId} updated", taskId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskUpdated", task);
}
public async Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying task {TaskId} deleted", taskId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskDeleted", new { TaskId = taskId });
}
public async Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying task {TaskId} assigned to {AssigneeId}", taskId, assigneeId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskAssigned", new
{
TaskId = taskId,
AssigneeId = assigneeId,
AssignedAt = DateTime.UtcNow
});
}
public async Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue)
{
var groupName = $"project-{projectId}";

View File

@@ -0,0 +1,44 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for EpicCreatedEvent - sends SignalR notification
/// </summary>
public class EpicCreatedEventHandler : INotificationHandler<EpicCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<EpicCreatedEventHandler> _logger;
public EpicCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(EpicCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling EpicCreatedEvent for epic {EpicId}", notification.EpicId);
var epicData = new
{
Id = notification.EpicId.Value,
ProjectId = notification.ProjectId.Value,
Name = notification.EpicName,
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyEpicCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
epicData);
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for EpicDeletedEvent - sends SignalR notification
/// </summary>
public class EpicDeletedEventHandler : INotificationHandler<EpicDeletedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<EpicDeletedEventHandler> _logger;
public EpicDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicDeletedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(EpicDeletedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling EpicDeletedEvent for epic {EpicId}", notification.EpicId);
await _notificationService.NotifyEpicDeleted(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value);
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for EpicUpdatedEvent - sends SignalR notification
/// </summary>
public class EpicUpdatedEventHandler : INotificationHandler<EpicUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<EpicUpdatedEventHandler> _logger;
public EpicUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicUpdatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(EpicUpdatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling EpicUpdatedEvent for epic {EpicId}", notification.EpicId);
var epicData = new
{
Id = notification.EpicId.Value,
ProjectId = notification.ProjectId.Value,
Name = notification.EpicName,
UpdatedAt = DateTime.UtcNow
};
await _notificationService.NotifyEpicUpdated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
epicData);
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for StoryCreatedEvent - sends SignalR notification
/// </summary>
public class StoryCreatedEventHandler : INotificationHandler<StoryCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<StoryCreatedEventHandler> _logger;
public StoryCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(StoryCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling StoryCreatedEvent for story {StoryId}", notification.StoryId);
var storyData = new
{
Id = notification.StoryId.Value,
ProjectId = notification.ProjectId.Value,
EpicId = notification.EpicId.Value,
Title = notification.StoryTitle,
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyStoryCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
notification.StoryId.Value,
storyData);
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for StoryDeletedEvent - sends SignalR notification
/// </summary>
public class StoryDeletedEventHandler : INotificationHandler<StoryDeletedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<StoryDeletedEventHandler> _logger;
public StoryDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryDeletedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(StoryDeletedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling StoryDeletedEvent for story {StoryId}", notification.StoryId);
await _notificationService.NotifyStoryDeleted(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
notification.StoryId.Value);
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for StoryUpdatedEvent - sends SignalR notification
/// </summary>
public class StoryUpdatedEventHandler : INotificationHandler<StoryUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<StoryUpdatedEventHandler> _logger;
public StoryUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryUpdatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(StoryUpdatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling StoryUpdatedEvent for story {StoryId}", notification.StoryId);
var storyData = new
{
Id = notification.StoryId.Value,
ProjectId = notification.ProjectId.Value,
EpicId = notification.EpicId.Value,
Title = notification.StoryTitle,
UpdatedAt = DateTime.UtcNow
};
await _notificationService.NotifyStoryUpdated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
notification.StoryId.Value,
storyData);
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
}
}

View File

@@ -0,0 +1,37 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for TaskAssignedEvent - sends SignalR notification
/// </summary>
public class TaskAssignedEventHandler : INotificationHandler<TaskAssignedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<TaskAssignedEventHandler> _logger;
public TaskAssignedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskAssignedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(TaskAssignedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling TaskAssignedEvent for task {TaskId}", notification.TaskId);
await _notificationService.NotifyTaskAssigned(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.TaskId.Value,
notification.AssigneeId.Value);
_logger.LogInformation("SignalR notification sent for task {TaskId} assigned to {AssigneeId}",
notification.TaskId, notification.AssigneeId);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for TaskCreatedEvent - sends SignalR notification
/// </summary>
public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<TaskCreatedEventHandler> _logger;
public TaskCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(TaskCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling TaskCreatedEvent for task {TaskId}", notification.TaskId);
var taskData = new
{
Id = notification.TaskId.Value,
ProjectId = notification.ProjectId.Value,
StoryId = notification.StoryId.Value,
Title = notification.TaskTitle,
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyTaskCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.StoryId.Value,
notification.TaskId.Value,
taskData);
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for TaskDeletedEvent - sends SignalR notification
/// </summary>
public class TaskDeletedEventHandler : INotificationHandler<TaskDeletedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<TaskDeletedEventHandler> _logger;
public TaskDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskDeletedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(TaskDeletedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling TaskDeletedEvent for task {TaskId}", notification.TaskId);
await _notificationService.NotifyTaskDeleted(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.StoryId.Value,
notification.TaskId.Value);
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.Extensions.Logging;
using ColaFlow.Modules.ProjectManagement.Domain.Events;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for TaskUpdatedEvent - sends SignalR notification
/// </summary>
public class TaskUpdatedEventHandler : INotificationHandler<TaskUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<TaskUpdatedEventHandler> _logger;
public TaskUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskUpdatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(TaskUpdatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling TaskUpdatedEvent for task {TaskId}", notification.TaskId);
var taskData = new
{
Id = notification.TaskId.Value,
ProjectId = notification.ProjectId.Value,
StoryId = notification.StoryId.Value,
Title = notification.TaskTitle,
UpdatedAt = DateTime.UtcNow
};
await _notificationService.NotifyTaskUpdated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.StoryId.Value,
notification.TaskId.Value,
taskData);
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
}
}

View File

@@ -5,7 +5,24 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Services;
/// </summary>
public interface IProjectNotificationService
{
// Project notifications
Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project);
Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project);
Task NotifyProjectArchived(Guid tenantId, Guid projectId);
// Epic notifications
Task NotifyEpicCreated(Guid tenantId, Guid projectId, Guid epicId, object epic);
Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic);
Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId);
// Story notifications
Task NotifyStoryCreated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story);
Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story);
Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId);
// Task notifications
Task NotifyTaskCreated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task);
Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task);
Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId);
Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId);
}

View File

@@ -90,11 +90,154 @@ public class Project : AggregateRoot
var epic = Epic.Create(this.TenantId, name, description, this.Id, createdBy);
_epics.Add(epic);
AddDomainEvent(new EpicCreatedEvent(epic.Id, epic.Name, this.Id));
AddDomainEvent(new EpicCreatedEvent(epic.Id, this.TenantId, this.Id, epic.Name));
return epic;
}
public void UpdateEpic(EpicId epicId, string name, string description)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
epic.UpdateDetails(name, description);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new EpicUpdatedEvent(epic.Id, this.TenantId, this.Id, epic.Name));
}
public void DeleteEpic(EpicId epicId)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
if (epic.Stories.Any())
throw new DomainException($"Cannot delete epic with ID {epicId.Value}. The epic has {epic.Stories.Count} associated story/stories. Please delete or reassign the stories first.");
_epics.Remove(epic);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new EpicDeletedEvent(epicId, this.TenantId, this.Id));
}
public Story CreateStory(EpicId epicId, string title, string description, TaskPriority priority, UserId createdBy)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
var story = epic.CreateStory(title, description, priority, createdBy);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new StoryCreatedEvent(story.Id, this.TenantId, this.Id, epicId, story.Title));
return story;
}
public void UpdateStory(EpicId epicId, StoryId storyId, string title, string description)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
var story = epic.Stories.FirstOrDefault(s => s.Id == storyId);
if (story == null)
throw new DomainException($"Story with ID {storyId.Value} not found in epic");
story.UpdateDetails(title, description);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new StoryUpdatedEvent(storyId, this.TenantId, this.Id, epicId, story.Title));
}
public void DeleteStory(EpicId epicId, StoryId storyId)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
epic.RemoveStory(storyId);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new StoryDeletedEvent(storyId, this.TenantId, this.Id, epicId));
}
public WorkTask CreateTask(EpicId epicId, StoryId storyId, string title, string description, TaskPriority priority, UserId createdBy)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
var story = epic.Stories.FirstOrDefault(s => s.Id == storyId);
if (story == null)
throw new DomainException($"Story with ID {storyId.Value} not found in epic");
var task = story.CreateTask(title, description, priority, createdBy);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TaskCreatedEvent(task.Id, this.TenantId, this.Id, storyId, task.Title));
return task;
}
public void UpdateTask(EpicId epicId, StoryId storyId, TaskId taskId, string title, string description)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
var story = epic.Stories.FirstOrDefault(s => s.Id == storyId);
if (story == null)
throw new DomainException($"Story with ID {storyId.Value} not found in epic");
var task = story.Tasks.FirstOrDefault(t => t.Id == taskId);
if (task == null)
throw new DomainException($"Task with ID {taskId.Value} not found in story");
task.UpdateDetails(title, description);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TaskUpdatedEvent(taskId, this.TenantId, this.Id, storyId, task.Title));
}
public void DeleteTask(EpicId epicId, StoryId storyId, TaskId taskId)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
var story = epic.Stories.FirstOrDefault(s => s.Id == storyId);
if (story == null)
throw new DomainException($"Story with ID {storyId.Value} not found in epic");
story.RemoveTask(taskId);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TaskDeletedEvent(taskId, this.TenantId, this.Id, storyId));
}
public void AssignTask(EpicId epicId, StoryId storyId, TaskId taskId, UserId assigneeId)
{
var epic = _epics.FirstOrDefault(e => e.Id == epicId);
if (epic == null)
throw new DomainException($"Epic with ID {epicId.Value} not found");
var story = epic.Stories.FirstOrDefault(s => s.Id == storyId);
if (story == null)
throw new DomainException($"Story with ID {storyId.Value} not found in epic");
var task = story.Tasks.FirstOrDefault(t => t.Id == taskId);
if (task == null)
throw new DomainException($"Task with ID {taskId.Value} not found in story");
task.AssignTo(assigneeId);
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new TaskAssignedEvent(taskId, this.TenantId, this.Id, storyId, assigneeId));
}
public void Archive()
{
if (Status == ProjectStatus.Archived)

View File

@@ -8,6 +8,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// </summary>
public sealed record EpicCreatedEvent(
EpicId EpicId,
string EpicName,
ProjectId ProjectId
TenantId TenantId,
ProjectId ProjectId,
string EpicName
) : DomainEvent;

View File

@@ -0,0 +1,13 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when an epic is deleted
/// </summary>
public sealed record EpicDeletedEvent(
EpicId EpicId,
TenantId TenantId,
ProjectId ProjectId
) : DomainEvent;

View File

@@ -0,0 +1,14 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when an epic is updated
/// </summary>
public sealed record EpicUpdatedEvent(
EpicId EpicId,
TenantId TenantId,
ProjectId ProjectId,
string EpicName
) : DomainEvent;

View File

@@ -0,0 +1,15 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a story is created
/// </summary>
public sealed record StoryCreatedEvent(
StoryId StoryId,
TenantId TenantId,
ProjectId ProjectId,
EpicId EpicId,
string StoryTitle
) : DomainEvent;

View File

@@ -0,0 +1,14 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a story is deleted
/// </summary>
public sealed record StoryDeletedEvent(
StoryId StoryId,
TenantId TenantId,
ProjectId ProjectId,
EpicId EpicId
) : DomainEvent;

View File

@@ -0,0 +1,15 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a story is updated
/// </summary>
public sealed record StoryUpdatedEvent(
StoryId StoryId,
TenantId TenantId,
ProjectId ProjectId,
EpicId EpicId,
string StoryTitle
) : DomainEvent;

View File

@@ -0,0 +1,15 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a task is assigned to a user
/// </summary>
public sealed record TaskAssignedEvent(
TaskId TaskId,
TenantId TenantId,
ProjectId ProjectId,
StoryId StoryId,
UserId AssigneeId
) : DomainEvent;

View File

@@ -0,0 +1,15 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a task is created
/// </summary>
public sealed record TaskCreatedEvent(
TaskId TaskId,
TenantId TenantId,
ProjectId ProjectId,
StoryId StoryId,
string TaskTitle
) : DomainEvent;

View File

@@ -0,0 +1,14 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a task is deleted
/// </summary>
public sealed record TaskDeletedEvent(
TaskId TaskId,
TenantId TenantId,
ProjectId ProjectId,
StoryId StoryId
) : DomainEvent;

View File

@@ -0,0 +1,15 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
/// <summary>
/// Event raised when a task is updated
/// </summary>
public sealed record TaskUpdatedEvent(
TaskId TaskId,
TenantId TenantId,
ProjectId ProjectId,
StoryId StoryId,
string TaskTitle
) : DomainEvent;

View File

@@ -125,16 +125,18 @@ public class DomainEventsTests
{
// Arrange
var epicId = EpicId.Create();
var epicName = "Epic 1";
var tenantId = TenantId.Create(Guid.NewGuid());
var projectId = ProjectId.Create();
var epicName = "Epic 1";
// Act
var @event = new EpicCreatedEvent(epicId, epicName, projectId);
var @event = new EpicCreatedEvent(epicId, tenantId, projectId, epicName);
// Assert
@event.EpicId.Should().Be(epicId);
@event.EpicName.Should().Be(epicName);
@event.TenantId.Should().Be(tenantId);
@event.ProjectId.Should().Be(projectId);
@event.EpicName.Should().Be(epicName);
@event.OccurredOn.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
@@ -143,17 +145,19 @@ public class DomainEventsTests
{
// Arrange
var epicId = EpicId.Create();
var epicName = "Epic 1";
var tenantId = TenantId.Create(Guid.NewGuid());
var projectId = ProjectId.Create();
var epicName = "Epic 1";
// Act
var event1 = new EpicCreatedEvent(epicId, epicName, projectId);
var event2 = new EpicCreatedEvent(epicId, epicName, projectId);
var event1 = new EpicCreatedEvent(epicId, tenantId, projectId, epicName);
var event2 = new EpicCreatedEvent(epicId, tenantId, projectId, epicName);
// Assert - Records with same values should be equal
event1.EpicId.Should().Be(event2.EpicId);
event1.EpicName.Should().Be(event2.EpicName);
event1.TenantId.Should().Be(event2.TenantId);
event1.ProjectId.Should().Be(event2.ProjectId);
event1.EpicName.Should().Be(event2.EpicName);
}
#endregion
@@ -167,7 +171,7 @@ public class DomainEventsTests
var projectCreatedEvent = new ProjectCreatedEvent(ProjectId.Create(), TenantId.Create(Guid.NewGuid()), "Test", UserId.Create());
var projectUpdatedEvent = new ProjectUpdatedEvent(ProjectId.Create(), "Test", "Desc");
var projectArchivedEvent = new ProjectArchivedEvent(ProjectId.Create());
var epicCreatedEvent = new EpicCreatedEvent(EpicId.Create(), "Epic", ProjectId.Create());
var epicCreatedEvent = new EpicCreatedEvent(EpicId.Create(), TenantId.Create(Guid.NewGuid()), ProjectId.Create(), "Epic");
// Assert
projectCreatedEvent.OccurredOn.Kind.Should().Be(DateTimeKind.Utc);