diff --git a/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs b/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs index 35b9ee3..0e45592 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs @@ -1,9 +1,13 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ColaFlow.Modules.ProjectManagement.Application.DTOs; using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject; +using ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateProject; +using ColaFlow.Modules.ProjectManagement.Application.Commands.ArchiveProject; using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById; using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects; +using System.Security.Claims; namespace ColaFlow.API.Controllers; @@ -12,6 +16,7 @@ namespace ColaFlow.API.Controllers; /// [ApiController] [Route("api/v1/[controller]")] +[Authorize] public class ProjectsController(IMediator mediator) : ControllerBase { private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); @@ -47,11 +52,73 @@ public class ProjectsController(IMediator mediator) : ControllerBase [HttpPost] [ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task CreateProject( [FromBody] CreateProjectCommand command, CancellationToken cancellationToken = default) { - var result = await _mediator.Send(command, cancellationToken); + // Extract TenantId and UserId from JWT claims + var tenantId = GetTenantIdFromClaims(); + var userId = GetUserIdFromClaims(); + + // Override command with authenticated user's context + var commandWithContext = command with + { + TenantId = tenantId, + OwnerId = userId + }; + + var result = await _mediator.Send(commandWithContext, cancellationToken); return CreatedAtAction(nameof(GetProject), new { id = result.Id }, result); } + + /// + /// Update an existing project + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task UpdateProject( + Guid id, + [FromBody] UpdateProjectCommand command, + CancellationToken cancellationToken = default) + { + var commandWithId = command with { ProjectId = id }; + var result = await _mediator.Send(commandWithId, cancellationToken); + return Ok(result); + } + + /// + /// Archive a project + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ArchiveProject( + Guid id, + CancellationToken cancellationToken = default) + { + await _mediator.Send(new ArchiveProjectCommand(id), cancellationToken); + return NoContent(); + } + + // Helper methods to extract claims + private Guid GetTenantIdFromClaims() + { + var tenantIdClaim = User.FindFirst("tenant_id")?.Value + ?? throw new UnauthorizedAccessException("Tenant ID not found in token"); + + return Guid.Parse(tenantIdClaim); + } + + private Guid GetUserIdFromClaims() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.FindFirst("sub")?.Value + ?? throw new UnauthorizedAccessException("User ID not found in token"); + + return Guid.Parse(userIdClaim); + } } diff --git a/colaflow-api/src/ColaFlow.API/Program.cs b/colaflow-api/src/ColaFlow.API/Program.cs index a3f8165..947b16d 100644 --- a/colaflow-api/src/ColaFlow.API/Program.cs +++ b/colaflow-api/src/ColaFlow.API/Program.cs @@ -142,6 +142,10 @@ builder.Services.AddSignalR(options => // Register Realtime Notification Service builder.Services.AddScoped(); +// Register Project Notification Service Adapter (for ProjectManagement module) +builder.Services.AddScoped(); + // Configure OpenAPI/Scalar builder.Services.AddOpenApi(); diff --git a/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs b/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs index 08d5655..d2b1011 100644 --- a/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs +++ b/colaflow-api/src/ColaFlow.API/Services/IRealtimeNotificationService.cs @@ -3,7 +3,12 @@ namespace ColaFlow.API.Services; public interface IRealtimeNotificationService { // Project-level notifications + Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project); + Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project); + Task NotifyProjectArchived(Guid tenantId, Guid projectId); Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data); + + // Issue notifications Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue); Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue); Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId); diff --git a/colaflow-api/src/ColaFlow.API/Services/ProjectNotificationServiceAdapter.cs b/colaflow-api/src/ColaFlow.API/Services/ProjectNotificationServiceAdapter.cs new file mode 100644 index 0000000..0ea74bf --- /dev/null +++ b/colaflow-api/src/ColaFlow.API/Services/ProjectNotificationServiceAdapter.cs @@ -0,0 +1,32 @@ +using ColaFlow.Modules.ProjectManagement.Application.Services; + +namespace ColaFlow.API.Services; + +/// +/// Adapter that implements IProjectNotificationService by delegating to IRealtimeNotificationService +/// This allows the ProjectManagement module to send notifications without depending on the API layer +/// +public class ProjectNotificationServiceAdapter : IProjectNotificationService +{ + private readonly IRealtimeNotificationService _realtimeService; + + public ProjectNotificationServiceAdapter(IRealtimeNotificationService realtimeService) + { + _realtimeService = realtimeService; + } + + public Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project) + { + return _realtimeService.NotifyProjectCreated(tenantId, projectId, project); + } + + public Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project) + { + return _realtimeService.NotifyProjectUpdated(tenantId, projectId, project); + } + + public Task NotifyProjectArchived(Guid tenantId, Guid projectId) + { + return _realtimeService.NotifyProjectArchived(tenantId, projectId); + } +} diff --git a/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs b/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs index af0327d..e72dbaf 100644 --- a/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs +++ b/colaflow-api/src/ColaFlow.API/Services/RealtimeNotificationService.cs @@ -19,6 +19,37 @@ public class RealtimeNotificationService : IRealtimeNotificationService _logger = logger; } + public async Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project) + { + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying tenant {TenantId} of new project {ProjectId}", tenantId, projectId); + + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectCreated", project); + } + + public async Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project) + { + var projectGroupName = $"project-{projectId}"; + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying project {ProjectId} updated", projectId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectUpdated", project); + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectUpdated", project); + } + + public async Task NotifyProjectArchived(Guid tenantId, Guid projectId) + { + var projectGroupName = $"project-{projectId}"; + var tenantGroupName = $"tenant-{tenantId}"; + + _logger.LogInformation("Notifying project {ProjectId} archived", projectId); + + await _projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectArchived", new { ProjectId = projectId }); + await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectArchived", new { ProjectId = projectId }); + } + public async Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data) { var groupName = $"project-{projectId}"; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommand.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommand.cs new file mode 100644 index 0000000..e39c4ec --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace ColaFlow.Modules.ProjectManagement.Application.Commands.ArchiveProject; + +/// +/// Command to archive a project +/// +public sealed record ArchiveProjectCommand(Guid ProjectId) : IRequest; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommandHandler.cs new file mode 100644 index 0000000..b3a5fe0 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommandHandler.cs @@ -0,0 +1,37 @@ +using MediatR; +using ColaFlow.Modules.ProjectManagement.Domain.Repositories; +using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; +using ColaFlow.Modules.ProjectManagement.Domain.Exceptions; + +namespace ColaFlow.Modules.ProjectManagement.Application.Commands.ArchiveProject; + +/// +/// Handler for ArchiveProjectCommand +/// +public sealed class ArchiveProjectCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); + + public async Task Handle(ArchiveProjectCommand request, CancellationToken cancellationToken) + { + // Get project (will be filtered by tenant automatically) + var project = await _projectRepository.GetByIdAsync(ProjectId.From(request.ProjectId), cancellationToken); + if (project == null) + { + throw new NotFoundException($"Project with ID '{request.ProjectId}' not found"); + } + + // Archive project + project.Archive(); + + // Save changes + _projectRepository.Update(project); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommandValidator.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommandValidator.cs new file mode 100644 index 0000000..1c18cd7 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/ArchiveProject/ArchiveProjectCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace ColaFlow.Modules.ProjectManagement.Application.Commands.ArchiveProject; + +/// +/// Validator for ArchiveProjectCommand +/// +public class ArchiveProjectCommandValidator : AbstractValidator +{ + public ArchiveProjectCommandValidator() + { + RuleFor(x => x.ProjectId) + .NotEmpty() + .WithMessage("ProjectId is required"); + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommand.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommand.cs index 5113581..d0f5ed3 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommand.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommand.cs @@ -8,6 +8,7 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject; /// public sealed record CreateProjectCommand : IRequest { + public Guid TenantId { get; init; } public string Name { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; public string Key { get; init; } = string.Empty; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs index d8694fa..9766bd5 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs @@ -29,6 +29,7 @@ public sealed class CreateProjectCommandHandler( // Create project aggregate var project = Project.Create( + TenantId.From(request.TenantId), request.Name, request.Description, request.Key, diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandValidator.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandValidator.cs index 20bca76..dd51c67 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandValidator.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandValidator.cs @@ -18,7 +18,7 @@ public sealed class CreateProjectCommandValidator : AbstractValidator x.OwnerId) - .NotEmpty().WithMessage("Owner ID is required"); + // TenantId and OwnerId are set by the controller from JWT claims, not from request body + // So we don't validate them here (they'll be Guid.Empty from request, then overridden) } } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommand.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommand.cs new file mode 100644 index 0000000..238d7f6 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using ColaFlow.Modules.ProjectManagement.Application.DTOs; + +namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateProject; + +/// +/// Command to update an existing project +/// +public sealed record UpdateProjectCommand : IRequest +{ + public Guid ProjectId { get; init; } + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommandHandler.cs new file mode 100644 index 0000000..5101856 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommandHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using ColaFlow.Modules.ProjectManagement.Application.DTOs; +using ColaFlow.Modules.ProjectManagement.Domain.Repositories; +using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; +using ColaFlow.Modules.ProjectManagement.Domain.Exceptions; + +namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateProject; + +/// +/// Handler for UpdateProjectCommand +/// +public sealed class UpdateProjectCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler +{ + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); + + public async Task Handle(UpdateProjectCommand request, CancellationToken cancellationToken) + { + // Get project (will be filtered by tenant automatically) + var project = await _projectRepository.GetByIdAsync(ProjectId.From(request.ProjectId), cancellationToken); + if (project == null) + { + throw new NotFoundException($"Project with ID '{request.ProjectId}' not found"); + } + + // Update project details + project.UpdateDetails(request.Name, request.Description); + + // Save changes + _projectRepository.Update(project); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + // Return DTO + return new ProjectDto + { + Id = project.Id.Value, + Name = project.Name, + Description = project.Description, + Key = project.Key.Value, + Status = project.Status.Name, + OwnerId = project.OwnerId.Value, + CreatedAt = project.CreatedAt, + UpdatedAt = project.UpdatedAt, + Epics = new List() + }; + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommandValidator.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommandValidator.cs new file mode 100644 index 0000000..6418a24 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateProject/UpdateProjectCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateProject; + +/// +/// Validator for UpdateProjectCommand +/// +public class UpdateProjectCommandValidator : AbstractValidator +{ + public UpdateProjectCommandValidator() + { + RuleFor(x => x.ProjectId) + .NotEmpty() + .WithMessage("ProjectId is required"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Project name is required") + .MaximumLength(200) + .WithMessage("Project name cannot exceed 200 characters"); + + RuleFor(x => x.Description) + .MaximumLength(2000) + .WithMessage("Project description cannot exceed 2000 characters"); + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectArchivedEventHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectArchivedEventHandler.cs new file mode 100644 index 0000000..7e94a0d --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectArchivedEventHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using ColaFlow.Modules.ProjectManagement.Domain.Events; +using ColaFlow.Modules.ProjectManagement.Domain.Repositories; +using ColaFlow.Modules.ProjectManagement.Application.Services; + +namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers; + +/// +/// Handler for ProjectArchivedEvent - sends SignalR notification +/// +public class ProjectArchivedEventHandler : INotificationHandler +{ + private readonly IProjectNotificationService _notificationService; + private readonly IProjectRepository _projectRepository; + private readonly ILogger _logger; + + public ProjectArchivedEventHandler( + IProjectNotificationService notificationService, + IProjectRepository projectRepository, + ILogger logger) + { + _notificationService = notificationService; + _projectRepository = projectRepository; + _logger = logger; + } + + public async Task Handle(ProjectArchivedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("Handling ProjectArchivedEvent for project {ProjectId}", notification.ProjectId); + + // Get full project to obtain TenantId + var project = await _projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken); + if (project == null) + { + _logger.LogWarning("Project {ProjectId} not found for archive notification", notification.ProjectId); + return; + } + + await _notificationService.NotifyProjectArchived( + project.TenantId.Value, + notification.ProjectId.Value); + + _logger.LogInformation("SignalR notification sent for archived project {ProjectId}", notification.ProjectId); + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectCreatedEventHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectCreatedEventHandler.cs new file mode 100644 index 0000000..b8fe147 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectCreatedEventHandler.cs @@ -0,0 +1,43 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using ColaFlow.Modules.ProjectManagement.Domain.Events; +using ColaFlow.Modules.ProjectManagement.Application.Services; + +namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers; + +/// +/// Handler for ProjectCreatedEvent - sends SignalR notification +/// +public class ProjectCreatedEventHandler : INotificationHandler +{ + private readonly IProjectNotificationService _notificationService; + private readonly ILogger _logger; + + public ProjectCreatedEventHandler( + IProjectNotificationService notificationService, + ILogger logger) + { + _notificationService = notificationService; + _logger = logger; + } + + public async Task Handle(ProjectCreatedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("Handling ProjectCreatedEvent for project {ProjectId}", notification.ProjectId); + + var projectData = new + { + Id = notification.ProjectId.Value, + Name = notification.ProjectName, + CreatedBy = notification.CreatedBy.Value, + CreatedAt = DateTime.UtcNow + }; + + await _notificationService.NotifyProjectCreated( + notification.TenantId.Value, + notification.ProjectId.Value, + projectData); + + _logger.LogInformation("SignalR notification sent for project {ProjectId}", notification.ProjectId); + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectUpdatedEventHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectUpdatedEventHandler.cs new file mode 100644 index 0000000..1a0e7e4 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/EventHandlers/ProjectUpdatedEventHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using ColaFlow.Modules.ProjectManagement.Domain.Events; +using ColaFlow.Modules.ProjectManagement.Domain.Repositories; +using ColaFlow.Modules.ProjectManagement.Application.Services; + +namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers; + +/// +/// Handler for ProjectUpdatedEvent - sends SignalR notification +/// +public class ProjectUpdatedEventHandler : INotificationHandler +{ + private readonly IProjectNotificationService _notificationService; + private readonly IProjectRepository _projectRepository; + private readonly ILogger _logger; + + public ProjectUpdatedEventHandler( + IProjectNotificationService notificationService, + IProjectRepository projectRepository, + ILogger logger) + { + _notificationService = notificationService; + _projectRepository = projectRepository; + _logger = logger; + } + + public async Task Handle(ProjectUpdatedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("Handling ProjectUpdatedEvent for project {ProjectId}", notification.ProjectId); + + // Get full project to obtain TenantId + var project = await _projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken); + if (project == null) + { + _logger.LogWarning("Project {ProjectId} not found for update notification", notification.ProjectId); + return; + } + + var projectData = new + { + Id = notification.ProjectId.Value, + Name = notification.Name, + Description = notification.Description, + UpdatedAt = DateTime.UtcNow + }; + + await _notificationService.NotifyProjectUpdated( + project.TenantId.Value, + notification.ProjectId.Value, + projectData); + + _logger.LogInformation("SignalR notification sent for updated project {ProjectId}", notification.ProjectId); + } +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Services/IProjectNotificationService.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Services/IProjectNotificationService.cs new file mode 100644 index 0000000..78cb719 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Services/IProjectNotificationService.cs @@ -0,0 +1,11 @@ +namespace ColaFlow.Modules.ProjectManagement.Application.Services; + +/// +/// Service for sending project-related notifications (abstraction for SignalR) +/// +public interface IProjectNotificationService +{ + Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project); + Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project); + Task NotifyProjectArchived(Guid tenantId, Guid projectId); +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs index a60a635..bd54c50 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Aggregates/ProjectAggregate/Project.cs @@ -13,6 +13,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate; public class Project : AggregateRoot { public new ProjectId Id { get; private set; } + public TenantId TenantId { get; private set; } public string Name { get; private set; } public string Description { get; private set; } public ProjectKey Key { get; private set; } @@ -29,6 +30,7 @@ public class Project : AggregateRoot private Project() { Id = null!; + TenantId = null!; Name = null!; Description = null!; Key = null!; @@ -37,7 +39,7 @@ public class Project : AggregateRoot } // Factory method - public static Project Create(string name, string description, string key, UserId ownerId) + public static Project Create(TenantId tenantId, string name, string description, string key, UserId ownerId) { // Validation if (string.IsNullOrWhiteSpace(name)) @@ -49,6 +51,7 @@ public class Project : AggregateRoot var project = new Project { Id = ProjectId.Create(), + TenantId = tenantId, Name = name, Description = description ?? string.Empty, Key = ProjectKey.Create(key), @@ -58,7 +61,7 @@ public class Project : AggregateRoot }; // Raise domain event - project.AddDomainEvent(new ProjectCreatedEvent(project.Id, project.Name, ownerId)); + project.AddDomainEvent(new ProjectCreatedEvent(project.Id, project.TenantId, project.Name, ownerId)); return project; } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/ProjectCreatedEvent.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/ProjectCreatedEvent.cs index f635806..32bcacb 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/ProjectCreatedEvent.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/Events/ProjectCreatedEvent.cs @@ -8,6 +8,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Events; /// public sealed record ProjectCreatedEvent( ProjectId ProjectId, + TenantId TenantId, string ProjectName, UserId CreatedBy ) : DomainEvent; diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/TenantId.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/TenantId.cs new file mode 100644 index 0000000..fee1eae --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Domain/ValueObjects/TenantId.cs @@ -0,0 +1,29 @@ +using ColaFlow.Shared.Kernel.Common; + +namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; + +/// +/// TenantId Value Object (strongly-typed ID) +/// +public sealed class TenantId : ValueObject +{ + public Guid Value { get; private set; } + + private TenantId(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("TenantId cannot be empty", nameof(value)); + + Value = value; + } + + public static TenantId Create(Guid value) => new TenantId(value); + public static TenantId From(Guid value) => new TenantId(value); + + protected override IEnumerable GetAtomicValues() + { + yield return Value; + } + + public override string ToString() => Value.ToString(); +} diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs index a6b9695..8e0fcd5 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs @@ -26,6 +26,13 @@ public class ProjectConfiguration : IEntityTypeConfiguration .IsRequired() .ValueGeneratedNever(); + // TenantId conversion + builder.Property(p => p.TenantId) + .HasConversion( + id => id.Value, + value => TenantId.From(value)) + .IsRequired(); + // Basic properties builder.Property(p => p.Name) .HasMaxLength(200) @@ -77,6 +84,7 @@ public class ProjectConfiguration : IEntityTypeConfiguration // Indexes for performance builder.HasIndex(p => p.CreatedAt); builder.HasIndex(p => p.OwnerId); + builder.HasIndex(p => p.TenantId); // Ignore DomainEvents (handled separately) builder.Ignore(p => p.DomainEvents); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs index 7f44514..3566f09 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs @@ -1,14 +1,24 @@ using System.Reflection; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate; +using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; /// /// Project Management Module DbContext /// -public class PMDbContext(DbContextOptions options) : DbContext(options) +public class PMDbContext : DbContext { + private readonly IHttpContextAccessor _httpContextAccessor; + + public PMDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor) + : base(options) + { + _httpContextAccessor = httpContextAccessor; + } + public DbSet Projects => Set(); public DbSet Epics => Set(); public DbSet Stories => Set(); @@ -23,5 +33,24 @@ public class PMDbContext(DbContextOptions options) : DbContext(opti // Apply all entity configurations from this assembly modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + + // Multi-tenant Global Query Filter for Project + modelBuilder.Entity().HasQueryFilter(p => + p.TenantId == GetCurrentTenantId()); + } + + private TenantId GetCurrentTenantId() + { + var tenantIdClaim = _httpContextAccessor?.HttpContext?.User + .FindFirst("tenant_id")?.Value; + + if (Guid.TryParse(tenantIdClaim, out var tenantId) && tenantId != Guid.Empty) + { + return TenantId.From(tenantId); + } + + // Return a dummy value for queries outside HTTP context (e.g., migrations) + // These will return no results due to the filter + return TenantId.From(Guid.Empty); } } diff --git a/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs b/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs index 4ab85f8..038fbbb 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ProjectManagementModule.cs @@ -32,6 +32,9 @@ public class ProjectManagementModule : IModule services.AddScoped(); services.AddScoped(); + // Note: IProjectNotificationService is registered in the API layer (Program.cs) + // as it depends on IRealtimeNotificationService which is API-specific + // Register MediatR handlers from Application assembly services.AddMediatR(cfg => {