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

@@ -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;
/// </summary>
[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<IActionResult> 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);
}
/// <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);
}
}

View File

@@ -142,6 +142,10 @@ builder.Services.AddSignalR(options =>
// Register Realtime Notification Service
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
builder.Services.AddOpenApi();

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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}";