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:
@@ -1,9 +1,13 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
|
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.GetProjectById;
|
||||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
|
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace ColaFlow.API.Controllers;
|
namespace ColaFlow.API.Controllers;
|
||||||
|
|
||||||
@@ -12,6 +16,7 @@ namespace ColaFlow.API.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/[controller]")]
|
[Route("api/v1/[controller]")]
|
||||||
|
[Authorize]
|
||||||
public class ProjectsController(IMediator mediator) : ControllerBase
|
public class ProjectsController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
@@ -47,11 +52,73 @@ public class ProjectsController(IMediator mediator) : ControllerBase
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<IActionResult> CreateProject(
|
public async Task<IActionResult> CreateProject(
|
||||||
[FromBody] CreateProjectCommand command,
|
[FromBody] CreateProjectCommand command,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
return CreatedAtAction(nameof(GetProject), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update an existing project
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Archive a project
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ builder.Services.AddSignalR(options =>
|
|||||||
// Register Realtime Notification Service
|
// Register Realtime Notification Service
|
||||||
builder.Services.AddScoped<IRealtimeNotificationService, RealtimeNotificationService>();
|
builder.Services.AddScoped<IRealtimeNotificationService, RealtimeNotificationService>();
|
||||||
|
|
||||||
|
// Register Project Notification Service Adapter (for ProjectManagement module)
|
||||||
|
builder.Services.AddScoped<ColaFlow.Modules.ProjectManagement.Application.Services.IProjectNotificationService,
|
||||||
|
ProjectNotificationServiceAdapter>();
|
||||||
|
|
||||||
// Configure OpenAPI/Scalar
|
// Configure OpenAPI/Scalar
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ namespace ColaFlow.API.Services;
|
|||||||
public interface IRealtimeNotificationService
|
public interface IRealtimeNotificationService
|
||||||
{
|
{
|
||||||
// Project-level notifications
|
// 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);
|
Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data);
|
||||||
|
|
||||||
|
// Issue notifications
|
||||||
Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue);
|
Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue);
|
||||||
Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue);
|
Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue);
|
||||||
Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId);
|
Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Services;
|
||||||
|
|
||||||
|
namespace ColaFlow.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter that implements IProjectNotificationService by delegating to IRealtimeNotificationService
|
||||||
|
/// This allows the ProjectManagement module to send notifications without depending on the API layer
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,37 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
|||||||
_logger = logger;
|
_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)
|
public async Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data)
|
||||||
{
|
{
|
||||||
var groupName = $"project-{projectId}";
|
var groupName = $"project-{projectId}";
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record CreateProjectCommand : IRequest<ProjectDto>
|
public sealed record CreateProjectCommand : IRequest<ProjectDto>
|
||||||
{
|
{
|
||||||
|
public Guid TenantId { get; init; }
|
||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
public string Description { get; init; } = string.Empty;
|
public string Description { get; init; } = string.Empty;
|
||||||
public string Key { get; init; } = string.Empty;
|
public string Key { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public sealed class CreateProjectCommandHandler(
|
|||||||
|
|
||||||
// Create project aggregate
|
// Create project aggregate
|
||||||
var project = Project.Create(
|
var project = Project.Create(
|
||||||
|
TenantId.From(request.TenantId),
|
||||||
request.Name,
|
request.Name,
|
||||||
request.Description,
|
request.Description,
|
||||||
request.Key,
|
request.Key,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProj
|
|||||||
.MaximumLength(20).WithMessage("Project key cannot exceed 20 characters")
|
.MaximumLength(20).WithMessage("Project key cannot exceed 20 characters")
|
||||||
.Matches("^[A-Z0-9]+$").WithMessage("Project key must contain only uppercase letters and numbers");
|
.Matches("^[A-Z0-9]+$").WithMessage("Project key must contain only uppercase letters and numbers");
|
||||||
|
|
||||||
RuleFor(x => x.OwnerId)
|
// TenantId and OwnerId are set by the controller from JWT claims, not from request body
|
||||||
.NotEmpty().WithMessage("Owner ID is required");
|
// So we don't validate them here (they'll be Guid.Empty from request, then overridden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
|||||||
public class Project : AggregateRoot
|
public class Project : AggregateRoot
|
||||||
{
|
{
|
||||||
public new ProjectId Id { get; private set; }
|
public new ProjectId Id { get; private set; }
|
||||||
|
public TenantId TenantId { get; private set; }
|
||||||
public string Name { get; private set; }
|
public string Name { get; private set; }
|
||||||
public string Description { get; private set; }
|
public string Description { get; private set; }
|
||||||
public ProjectKey Key { get; private set; }
|
public ProjectKey Key { get; private set; }
|
||||||
@@ -29,6 +30,7 @@ public class Project : AggregateRoot
|
|||||||
private Project()
|
private Project()
|
||||||
{
|
{
|
||||||
Id = null!;
|
Id = null!;
|
||||||
|
TenantId = null!;
|
||||||
Name = null!;
|
Name = null!;
|
||||||
Description = null!;
|
Description = null!;
|
||||||
Key = null!;
|
Key = null!;
|
||||||
@@ -37,7 +39,7 @@ public class Project : AggregateRoot
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Factory method
|
// 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
|
// Validation
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
@@ -49,6 +51,7 @@ public class Project : AggregateRoot
|
|||||||
var project = new Project
|
var project = new Project
|
||||||
{
|
{
|
||||||
Id = ProjectId.Create(),
|
Id = ProjectId.Create(),
|
||||||
|
TenantId = tenantId,
|
||||||
Name = name,
|
Name = name,
|
||||||
Description = description ?? string.Empty,
|
Description = description ?? string.Empty,
|
||||||
Key = ProjectKey.Create(key),
|
Key = ProjectKey.Create(key),
|
||||||
@@ -58,7 +61,7 @@ public class Project : AggregateRoot
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Raise domain event
|
// 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;
|
return project;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace ColaFlow.Modules.ProjectManagement.Domain.Events;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ProjectCreatedEvent(
|
public sealed record ProjectCreatedEvent(
|
||||||
ProjectId ProjectId,
|
ProjectId ProjectId,
|
||||||
|
TenantId TenantId,
|
||||||
string ProjectName,
|
string ProjectName,
|
||||||
UserId CreatedBy
|
UserId CreatedBy
|
||||||
) : DomainEvent;
|
) : DomainEvent;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ColaFlow.Shared.Kernel.Common;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TenantId Value Object (strongly-typed ID)
|
||||||
|
/// </summary>
|
||||||
|
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<object> GetAtomicValues()
|
||||||
|
{
|
||||||
|
yield return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Value.ToString();
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ public class ProjectConfiguration : IEntityTypeConfiguration<Project>
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedNever();
|
.ValueGeneratedNever();
|
||||||
|
|
||||||
|
// TenantId conversion
|
||||||
|
builder.Property(p => p.TenantId)
|
||||||
|
.HasConversion(
|
||||||
|
id => id.Value,
|
||||||
|
value => TenantId.From(value))
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
// Basic properties
|
// Basic properties
|
||||||
builder.Property(p => p.Name)
|
builder.Property(p => p.Name)
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -77,6 +84,7 @@ public class ProjectConfiguration : IEntityTypeConfiguration<Project>
|
|||||||
// Indexes for performance
|
// Indexes for performance
|
||||||
builder.HasIndex(p => p.CreatedAt);
|
builder.HasIndex(p => p.CreatedAt);
|
||||||
builder.HasIndex(p => p.OwnerId);
|
builder.HasIndex(p => p.OwnerId);
|
||||||
|
builder.HasIndex(p => p.TenantId);
|
||||||
|
|
||||||
// Ignore DomainEvents (handled separately)
|
// Ignore DomainEvents (handled separately)
|
||||||
builder.Ignore(p => p.DomainEvents);
|
builder.Ignore(p => p.DomainEvents);
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Project Management Module DbContext
|
/// Project Management Module DbContext
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PMDbContext(DbContextOptions<PMDbContext> options) : DbContext(options)
|
public class PMDbContext : DbContext
|
||||||
{
|
{
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
|
public PMDbContext(DbContextOptions<PMDbContext> options, IHttpContextAccessor httpContextAccessor)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
public DbSet<Project> Projects => Set<Project>();
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
public DbSet<Epic> Epics => Set<Epic>();
|
public DbSet<Epic> Epics => Set<Epic>();
|
||||||
public DbSet<Story> Stories => Set<Story>();
|
public DbSet<Story> Stories => Set<Story>();
|
||||||
@@ -23,5 +33,24 @@ public class PMDbContext(DbContextOptions<PMDbContext> options) : DbContext(opti
|
|||||||
|
|
||||||
// Apply all entity configurations from this assembly
|
// Apply all entity configurations from this assembly
|
||||||
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
|
// Multi-tenant Global Query Filter for Project
|
||||||
|
modelBuilder.Entity<Project>().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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class ProjectManagementModule : IModule
|
|||||||
services.AddScoped<IProjectRepository, ProjectRepository>();
|
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||||
|
|
||||||
|
// 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
|
// Register MediatR handlers from Application assembly
|
||||||
services.AddMediatR(cfg =>
|
services.AddMediatR(cfg =>
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user