feat(backend): Implement complete Project Management Module with multi-tenant support

Day 12 implementation - Complete CRUD operations with tenant isolation and SignalR integration.

**Domain Layer**:
- Added TenantId value object for strong typing
- Updated Project entity to include TenantId field
- Modified Project.Create factory method to require tenantId parameter
- Updated ProjectCreatedEvent to include TenantId

**Application Layer**:
- Created UpdateProjectCommand, Handler, and Validator for project updates
- Created ArchiveProjectCommand, Handler, and Validator for archiving projects
- Updated CreateProjectCommand to include TenantId
- Modified CreateProjectCommandValidator to remove OwnerId validation (set from JWT)
- Created IProjectNotificationService interface for SignalR abstraction
- Implemented ProjectCreatedEventHandler with SignalR notifications
- Implemented ProjectUpdatedEventHandler with SignalR notifications
- Implemented ProjectArchivedEventHandler with SignalR notifications

**Infrastructure Layer**:
- Updated PMDbContext to inject IHttpContextAccessor
- Configured Global Query Filter for automatic tenant isolation
- Added TenantId property mapping in ProjectConfiguration
- Created TenantId index for query performance

**API Layer**:
- Updated ProjectsController with [Authorize] attribute
- Implemented PUT /api/v1/projects/{id} for updates
- Implemented DELETE /api/v1/projects/{id} for archiving
- Added helper methods to extract TenantId and UserId from JWT claims
- Extended IRealtimeNotificationService with Project-specific methods
- Implemented RealtimeNotificationService with tenant-aware SignalR groups
- Created ProjectNotificationServiceAdapter to bridge layers
- Registered IProjectNotificationService in Program.cs

**Features Implemented**:
- Complete CRUD operations (Create, Read, Update, Archive)
- Multi-tenant isolation via EF Core Global Query Filter
- JWT-based authorization on all endpoints
- SignalR real-time notifications for all Project events
- Clean Architecture with proper layer separation
- Domain Event pattern with MediatR

**Database Migration**:
- Migration created (not applied yet): AddTenantIdToProject

**Test Scripts**:
- Created comprehensive test scripts (test-project-simple.ps1)
- Tests cover full CRUD lifecycle and tenant isolation

**Note**: API hot reload required to apply CreateProjectCommandValidator fix.

🤖 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 10:13:04 +01:00
parent 3843d07577
commit 9ada0cac4a
24 changed files with 526 additions and 6 deletions

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.ArchiveProject;
/// <summary>
/// Command to archive a project
/// </summary>
public sealed record ArchiveProjectCommand(Guid ProjectId) : IRequest<Unit>;

View File

@@ -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;
/// <summary>
/// Handler for ArchiveProjectCommand
/// </summary>
public sealed class ArchiveProjectCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<ArchiveProjectCommand, Unit>
{
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
public async Task<Unit> 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;
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.ArchiveProject;
/// <summary>
/// Validator for ArchiveProjectCommand
/// </summary>
public class ArchiveProjectCommandValidator : AbstractValidator<ArchiveProjectCommand>
{
public ArchiveProjectCommandValidator()
{
RuleFor(x => x.ProjectId)
.NotEmpty()
.WithMessage("ProjectId is required");
}
}

View File

@@ -8,6 +8,7 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
/// </summary>
public sealed record CreateProjectCommand : IRequest<ProjectDto>
{
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;

View File

@@ -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,

View File

@@ -18,7 +18,7 @@ public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProj
.MaximumLength(20).WithMessage("Project key cannot exceed 20 characters")
.Matches("^[A-Z0-9]+$").WithMessage("Project key must contain only uppercase letters and numbers");
RuleFor(x => 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)
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateProject;
/// <summary>
/// Command to update an existing project
/// </summary>
public sealed record UpdateProjectCommand : IRequest<ProjectDto>
{
public Guid ProjectId { get; init; }
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
}

View File

@@ -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;
/// <summary>
/// Handler for UpdateProjectCommand
/// </summary>
public sealed class UpdateProjectCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<UpdateProjectCommand, ProjectDto>
{
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
public async Task<ProjectDto> 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<EpicDto>()
};
}
}

View File

@@ -0,0 +1,26 @@
using FluentValidation;
namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateProject;
/// <summary>
/// Validator for UpdateProjectCommand
/// </summary>
public class UpdateProjectCommandValidator : AbstractValidator<UpdateProjectCommand>
{
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");
}
}

View File

@@ -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;
/// <summary>
/// Handler for ProjectArchivedEvent - sends SignalR notification
/// </summary>
public class ProjectArchivedEventHandler : INotificationHandler<ProjectArchivedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly IProjectRepository _projectRepository;
private readonly ILogger<ProjectArchivedEventHandler> _logger;
public ProjectArchivedEventHandler(
IProjectNotificationService notificationService,
IProjectRepository projectRepository,
ILogger<ProjectArchivedEventHandler> 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);
}
}

View File

@@ -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;
/// <summary>
/// Handler for ProjectCreatedEvent - sends SignalR notification
/// </summary>
public class ProjectCreatedEventHandler : INotificationHandler<ProjectCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<ProjectCreatedEventHandler> _logger;
public ProjectCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<ProjectCreatedEventHandler> 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);
}
}

View File

@@ -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;
/// <summary>
/// Handler for ProjectUpdatedEvent - sends SignalR notification
/// </summary>
public class ProjectUpdatedEventHandler : INotificationHandler<ProjectUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly IProjectRepository _projectRepository;
private readonly ILogger<ProjectUpdatedEventHandler> _logger;
public ProjectUpdatedEventHandler(
IProjectNotificationService notificationService,
IProjectRepository projectRepository,
ILogger<ProjectUpdatedEventHandler> 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);
}
}

View File

@@ -0,0 +1,11 @@
namespace ColaFlow.Modules.ProjectManagement.Application.Services;
/// <summary>
/// Service for sending project-related notifications (abstraction for SignalR)
/// </summary>
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);
}