Clean up
This commit is contained in:
@@ -13,18 +13,13 @@ namespace ColaFlow.API.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/mcp/keys")]
|
||||
[Authorize] // Requires JWT authentication
|
||||
public class McpApiKeysController : ControllerBase
|
||||
public class McpApiKeysController(
|
||||
IMcpApiKeyService apiKeyService,
|
||||
ILogger<McpApiKeysController> logger)
|
||||
: ControllerBase
|
||||
{
|
||||
private readonly IMcpApiKeyService _apiKeyService;
|
||||
private readonly ILogger<McpApiKeysController> _logger;
|
||||
|
||||
public McpApiKeysController(
|
||||
IMcpApiKeyService apiKeyService,
|
||||
ILogger<McpApiKeysController> logger)
|
||||
{
|
||||
_apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpApiKeyService _apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
|
||||
private readonly ILogger<McpApiKeysController> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <summary>
|
||||
/// Create a new API Key
|
||||
|
||||
@@ -14,18 +14,13 @@ namespace ColaFlow.API.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/mcp/pending-changes")]
|
||||
[Authorize] // Requires JWT authentication
|
||||
public class McpPendingChangesController : ControllerBase
|
||||
public class McpPendingChangesController(
|
||||
IPendingChangeService pendingChangeService,
|
||||
ILogger<McpPendingChangesController> logger)
|
||||
: ControllerBase
|
||||
{
|
||||
private readonly IPendingChangeService _pendingChangeService;
|
||||
private readonly ILogger<McpPendingChangesController> _logger;
|
||||
|
||||
public McpPendingChangesController(
|
||||
IPendingChangeService pendingChangeService,
|
||||
ILogger<McpPendingChangesController> logger)
|
||||
{
|
||||
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
private readonly ILogger<McpPendingChangesController> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <summary>
|
||||
/// Get list of pending changes with filtering and pagination
|
||||
|
||||
@@ -7,15 +7,8 @@ namespace ColaFlow.API.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class SignalRTestController : ControllerBase
|
||||
public class SignalRTestController(IRealtimeNotificationService notificationService) : ControllerBase
|
||||
{
|
||||
private readonly IRealtimeNotificationService _notificationService;
|
||||
|
||||
public SignalRTestController(IRealtimeNotificationService notificationService)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test sending notification to current user
|
||||
/// </summary>
|
||||
@@ -24,7 +17,7 @@ public class SignalRTestController : ControllerBase
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirst("sub")!.Value);
|
||||
|
||||
await _notificationService.NotifyUser(userId, message, "test");
|
||||
await notificationService.NotifyUser(userId, message, "test");
|
||||
|
||||
return Ok(new { message = "Notification sent", userId });
|
||||
}
|
||||
@@ -37,7 +30,7 @@ public class SignalRTestController : ControllerBase
|
||||
{
|
||||
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
|
||||
|
||||
await _notificationService.NotifyUsersInTenant(tenantId, message, "test");
|
||||
await notificationService.NotifyUsersInTenant(tenantId, message, "test");
|
||||
|
||||
return Ok(new { message = "Tenant notification sent", tenantId });
|
||||
}
|
||||
@@ -50,7 +43,7 @@ public class SignalRTestController : ControllerBase
|
||||
{
|
||||
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
|
||||
|
||||
await _notificationService.NotifyProjectUpdate(tenantId, request.ProjectId, new
|
||||
await notificationService.NotifyProjectUpdate(tenantId, request.ProjectId, new
|
||||
{
|
||||
Message = request.Message,
|
||||
UpdatedBy = User.FindFirst("sub")!.Value,
|
||||
@@ -68,7 +61,7 @@ public class SignalRTestController : ControllerBase
|
||||
{
|
||||
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
|
||||
|
||||
await _notificationService.NotifyIssueStatusChanged(
|
||||
await notificationService.NotifyIssueStatusChanged(
|
||||
tenantId,
|
||||
request.ProjectId,
|
||||
request.IssueId,
|
||||
|
||||
@@ -22,22 +22,15 @@ namespace ColaFlow.API.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/v1/sprints")]
|
||||
[Authorize]
|
||||
public class SprintsController : ControllerBase
|
||||
public class SprintsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public SprintsController(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new sprint
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SprintDto>> Create([FromBody] CreateSprintCommand command)
|
||||
{
|
||||
var result = await _mediator.Send(command);
|
||||
var result = await mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
@@ -50,7 +43,7 @@ public class SprintsController : ControllerBase
|
||||
if (id != command.SprintId)
|
||||
return BadRequest("Sprint ID mismatch");
|
||||
|
||||
await _mediator.Send(command);
|
||||
await mediator.Send(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -60,7 +53,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
await _mediator.Send(new DeleteSprintCommand(id));
|
||||
await mediator.Send(new DeleteSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -70,7 +63,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SprintDto>> GetById(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSprintByIdQuery(id));
|
||||
var result = await mediator.Send(new GetSprintByIdQuery(id));
|
||||
if (result == null)
|
||||
return NotFound();
|
||||
return Ok(result);
|
||||
@@ -82,7 +75,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<SprintDto>>> GetByProject([FromQuery] Guid projectId)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSprintsByProjectIdQuery(projectId));
|
||||
var result = await mediator.Send(new GetSprintsByProjectIdQuery(projectId));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -92,7 +85,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpGet("active")]
|
||||
public async Task<ActionResult<IReadOnlyList<SprintDto>>> GetActive()
|
||||
{
|
||||
var result = await _mediator.Send(new GetActiveSprintsQuery());
|
||||
var result = await mediator.Send(new GetActiveSprintsQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -102,7 +95,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpPost("{id}/start")]
|
||||
public async Task<IActionResult> Start(Guid id)
|
||||
{
|
||||
await _mediator.Send(new StartSprintCommand(id));
|
||||
await mediator.Send(new StartSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -112,7 +105,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpPost("{id}/complete")]
|
||||
public async Task<IActionResult> Complete(Guid id)
|
||||
{
|
||||
await _mediator.Send(new CompleteSprintCommand(id));
|
||||
await mediator.Send(new CompleteSprintCommand(id));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -122,7 +115,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpPost("{id}/tasks/{taskId}")]
|
||||
public async Task<IActionResult> AddTask(Guid id, Guid taskId)
|
||||
{
|
||||
await _mediator.Send(new AddTaskToSprintCommand(id, taskId));
|
||||
await mediator.Send(new AddTaskToSprintCommand(id, taskId));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -132,7 +125,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpDelete("{id}/tasks/{taskId}")]
|
||||
public async Task<IActionResult> RemoveTask(Guid id, Guid taskId)
|
||||
{
|
||||
await _mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
|
||||
await mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -142,7 +135,7 @@ public class SprintsController : ControllerBase
|
||||
[HttpGet("{id}/burndown")]
|
||||
public async Task<ActionResult<BurndownChartDto>> GetBurndown(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSprintBurndownQuery(id));
|
||||
var result = await mediator.Send(new GetSprintBurndownQuery(id));
|
||||
if (result == null)
|
||||
return NotFound();
|
||||
return Ok(result);
|
||||
|
||||
@@ -8,26 +8,20 @@ namespace ColaFlow.API.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handles Sprint domain events and sends SignalR notifications
|
||||
/// </summary>
|
||||
public class SprintEventHandlers :
|
||||
INotificationHandler<SprintCreatedEvent>,
|
||||
INotificationHandler<SprintUpdatedEvent>,
|
||||
INotificationHandler<SprintStartedEvent>,
|
||||
INotificationHandler<SprintCompletedEvent>,
|
||||
INotificationHandler<SprintDeletedEvent>
|
||||
public class SprintEventHandlers(
|
||||
IRealtimeNotificationService notificationService,
|
||||
ILogger<SprintEventHandlers> logger,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
:
|
||||
INotificationHandler<SprintCreatedEvent>,
|
||||
INotificationHandler<SprintUpdatedEvent>,
|
||||
INotificationHandler<SprintStartedEvent>,
|
||||
INotificationHandler<SprintCompletedEvent>,
|
||||
INotificationHandler<SprintDeletedEvent>
|
||||
{
|
||||
private readonly IRealtimeNotificationService _notificationService;
|
||||
private readonly ILogger<SprintEventHandlers> _logger;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public SprintEventHandlers(
|
||||
IRealtimeNotificationService notificationService,
|
||||
ILogger<SprintEventHandlers> logger,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
private readonly IRealtimeNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
private readonly ILogger<SprintEventHandlers> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
|
||||
public async Task Handle(SprintCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -6,15 +6,8 @@ namespace ColaFlow.API.Hubs;
|
||||
/// <summary>
|
||||
/// Project real-time collaboration Hub
|
||||
/// </summary>
|
||||
public class ProjectHub : BaseHub
|
||||
public class ProjectHub(IProjectPermissionService permissionService) : BaseHub
|
||||
{
|
||||
private readonly IProjectPermissionService _permissionService;
|
||||
|
||||
public ProjectHub(IProjectPermissionService permissionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join project room (to receive project-level updates)
|
||||
/// </summary>
|
||||
@@ -24,7 +17,7 @@ public class ProjectHub : BaseHub
|
||||
var userId = GetCurrentUserId();
|
||||
|
||||
// Validate user has permission to access this project
|
||||
var hasPermission = await _permissionService.IsUserProjectMemberAsync(
|
||||
var hasPermission = await permissionService.IsUserProjectMemberAsync(
|
||||
userId, projectId, Context.ConnectionAborted);
|
||||
|
||||
if (!hasPermission)
|
||||
@@ -54,7 +47,7 @@ public class ProjectHub : BaseHub
|
||||
var userId = GetCurrentUserId();
|
||||
|
||||
// Validate user has permission to access this project (for consistency)
|
||||
var hasPermission = await _permissionService.IsUserProjectMemberAsync(
|
||||
var hasPermission = await permissionService.IsUserProjectMemberAsync(
|
||||
userId, projectId, Context.ConnectionAborted);
|
||||
|
||||
if (!hasPermission)
|
||||
|
||||
@@ -5,21 +5,12 @@ namespace ColaFlow.API.Middleware;
|
||||
/// <summary>
|
||||
/// Middleware to log slow HTTP requests for performance monitoring
|
||||
/// </summary>
|
||||
public class PerformanceLoggingMiddleware
|
||||
public class PerformanceLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<PerformanceLoggingMiddleware> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<PerformanceLoggingMiddleware> _logger;
|
||||
private readonly int _slowRequestThresholdMs;
|
||||
|
||||
public PerformanceLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<PerformanceLoggingMiddleware> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_slowRequestThresholdMs = configuration.GetValue<int>("Performance:SlowRequestThresholdMs", 1000);
|
||||
}
|
||||
private readonly int _slowRequestThresholdMs = configuration.GetValue<int>("Performance:SlowRequestThresholdMs", 1000);
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
@@ -29,7 +20,7 @@ public class PerformanceLoggingMiddleware
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
await next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -39,7 +30,7 @@ public class PerformanceLoggingMiddleware
|
||||
// Log slow requests as warnings
|
||||
if (elapsedMs > _slowRequestThresholdMs)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Slow request detected: {Method} {Path} took {ElapsedMs}ms (Status: {StatusCode})",
|
||||
requestMethod,
|
||||
requestPath,
|
||||
@@ -49,7 +40,7 @@ public class PerformanceLoggingMiddleware
|
||||
else if (elapsedMs > _slowRequestThresholdMs / 2)
|
||||
{
|
||||
// Log moderately slow requests as information
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Request took {ElapsedMs}ms: {Method} {Path} (Status: {StatusCode})",
|
||||
elapsedMs,
|
||||
requestMethod,
|
||||
|
||||
@@ -6,81 +6,75 @@ 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
|
||||
/// </summary>
|
||||
public class ProjectNotificationServiceAdapter : IProjectNotificationService
|
||||
public class ProjectNotificationServiceAdapter(IRealtimeNotificationService realtimeService)
|
||||
: IProjectNotificationService
|
||||
{
|
||||
private readonly IRealtimeNotificationService _realtimeService;
|
||||
|
||||
public ProjectNotificationServiceAdapter(IRealtimeNotificationService realtimeService)
|
||||
{
|
||||
_realtimeService = realtimeService;
|
||||
}
|
||||
|
||||
// Project notifications
|
||||
public Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project)
|
||||
{
|
||||
return _realtimeService.NotifyProjectCreated(tenantId, projectId, project);
|
||||
return realtimeService.NotifyProjectCreated(tenantId, projectId, project);
|
||||
}
|
||||
|
||||
public Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project)
|
||||
{
|
||||
return _realtimeService.NotifyProjectUpdated(tenantId, projectId, project);
|
||||
return realtimeService.NotifyProjectUpdated(tenantId, projectId, project);
|
||||
}
|
||||
|
||||
public Task NotifyProjectArchived(Guid tenantId, Guid projectId)
|
||||
{
|
||||
return _realtimeService.NotifyProjectArchived(tenantId, projectId);
|
||||
return realtimeService.NotifyProjectArchived(tenantId, projectId);
|
||||
}
|
||||
|
||||
// Epic notifications
|
||||
public Task NotifyEpicCreated(Guid tenantId, Guid projectId, Guid epicId, object epic)
|
||||
{
|
||||
return _realtimeService.NotifyEpicCreated(tenantId, projectId, epicId, epic);
|
||||
return realtimeService.NotifyEpicCreated(tenantId, projectId, epicId, epic);
|
||||
}
|
||||
|
||||
public Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic)
|
||||
{
|
||||
return _realtimeService.NotifyEpicUpdated(tenantId, projectId, epicId, epic);
|
||||
return realtimeService.NotifyEpicUpdated(tenantId, projectId, epicId, epic);
|
||||
}
|
||||
|
||||
public Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId)
|
||||
{
|
||||
return _realtimeService.NotifyEpicDeleted(tenantId, projectId, epicId);
|
||||
return realtimeService.NotifyEpicDeleted(tenantId, projectId, epicId);
|
||||
}
|
||||
|
||||
// Story notifications
|
||||
public Task NotifyStoryCreated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
|
||||
{
|
||||
return _realtimeService.NotifyStoryCreated(tenantId, projectId, epicId, storyId, story);
|
||||
return realtimeService.NotifyStoryCreated(tenantId, projectId, epicId, storyId, story);
|
||||
}
|
||||
|
||||
public Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
|
||||
{
|
||||
return _realtimeService.NotifyStoryUpdated(tenantId, projectId, epicId, storyId, story);
|
||||
return realtimeService.NotifyStoryUpdated(tenantId, projectId, epicId, storyId, story);
|
||||
}
|
||||
|
||||
public Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId)
|
||||
{
|
||||
return _realtimeService.NotifyStoryDeleted(tenantId, projectId, epicId, storyId);
|
||||
return realtimeService.NotifyStoryDeleted(tenantId, projectId, epicId, storyId);
|
||||
}
|
||||
|
||||
// Task notifications
|
||||
public Task NotifyTaskCreated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
|
||||
{
|
||||
return _realtimeService.NotifyTaskCreated(tenantId, projectId, storyId, taskId, task);
|
||||
return realtimeService.NotifyTaskCreated(tenantId, projectId, storyId, taskId, task);
|
||||
}
|
||||
|
||||
public Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
|
||||
{
|
||||
return _realtimeService.NotifyTaskUpdated(tenantId, projectId, storyId, taskId, task);
|
||||
return realtimeService.NotifyTaskUpdated(tenantId, projectId, storyId, taskId, task);
|
||||
}
|
||||
|
||||
public Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId)
|
||||
{
|
||||
return _realtimeService.NotifyTaskDeleted(tenantId, projectId, storyId, taskId);
|
||||
return realtimeService.NotifyTaskDeleted(tenantId, projectId, storyId, taskId);
|
||||
}
|
||||
|
||||
public Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId)
|
||||
{
|
||||
return _realtimeService.NotifyTaskAssigned(tenantId, projectId, taskId, assigneeId);
|
||||
return realtimeService.NotifyTaskAssigned(tenantId, projectId, taskId, assigneeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,19 @@ using ColaFlow.API.Hubs;
|
||||
|
||||
namespace ColaFlow.API.Services;
|
||||
|
||||
public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
public class RealtimeNotificationService(
|
||||
IHubContext<ProjectHub> projectHubContext,
|
||||
IHubContext<NotificationHub> notificationHubContext,
|
||||
ILogger<RealtimeNotificationService> logger)
|
||||
: IRealtimeNotificationService
|
||||
{
|
||||
private readonly IHubContext<ProjectHub> _projectHubContext;
|
||||
private readonly IHubContext<NotificationHub> _notificationHubContext;
|
||||
private readonly ILogger<RealtimeNotificationService> _logger;
|
||||
|
||||
public RealtimeNotificationService(
|
||||
IHubContext<ProjectHub> projectHubContext,
|
||||
IHubContext<NotificationHub> notificationHubContext,
|
||||
ILogger<RealtimeNotificationService> logger)
|
||||
{
|
||||
_projectHubContext = projectHubContext;
|
||||
_notificationHubContext = notificationHubContext;
|
||||
_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);
|
||||
logger.LogInformation("Notifying tenant {TenantId} of new project {ProjectId}", tenantId, projectId);
|
||||
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectCreated", project);
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectCreated", project);
|
||||
}
|
||||
|
||||
public async Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project)
|
||||
@@ -33,10 +23,10 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying project {ProjectId} updated", projectId);
|
||||
logger.LogInformation("Notifying project {ProjectId} updated", projectId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectUpdated", project);
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectUpdated", project);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectUpdated", project);
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectUpdated", project);
|
||||
}
|
||||
|
||||
public async Task NotifyProjectArchived(Guid tenantId, Guid projectId)
|
||||
@@ -44,19 +34,19 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying project {ProjectId} archived", projectId);
|
||||
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 });
|
||||
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}";
|
||||
|
||||
_logger.LogInformation("Sending project update to group {GroupName}", groupName);
|
||||
logger.LogInformation("Sending project update to group {GroupName}", groupName);
|
||||
|
||||
await _projectHubContext.Clients.Group(groupName).SendAsync("ProjectUpdated", data);
|
||||
await projectHubContext.Clients.Group(groupName).SendAsync("ProjectUpdated", data);
|
||||
}
|
||||
|
||||
// Epic notifications
|
||||
@@ -65,28 +55,28 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying epic {EpicId} created in project {ProjectId}", epicId, projectId);
|
||||
logger.LogInformation("Notifying epic {EpicId} created in project {ProjectId}", epicId, projectId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicCreated", epic);
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("EpicCreated", epic);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicCreated", epic);
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("EpicCreated", epic);
|
||||
}
|
||||
|
||||
public async Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying epic {EpicId} updated", epicId);
|
||||
logger.LogInformation("Notifying epic {EpicId} updated", epicId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicUpdated", epic);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicUpdated", epic);
|
||||
}
|
||||
|
||||
public async Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying epic {EpicId} deleted", epicId);
|
||||
logger.LogInformation("Notifying epic {EpicId} deleted", epicId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicDeleted", new { EpicId = epicId });
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicDeleted", new { EpicId = epicId });
|
||||
}
|
||||
|
||||
// Story notifications
|
||||
@@ -95,28 +85,28 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying story {StoryId} created in epic {EpicId}", storyId, epicId);
|
||||
logger.LogInformation("Notifying story {StoryId} created in epic {EpicId}", storyId, epicId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryCreated", story);
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("StoryCreated", story);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryCreated", story);
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("StoryCreated", story);
|
||||
}
|
||||
|
||||
public async Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying story {StoryId} updated", storyId);
|
||||
logger.LogInformation("Notifying story {StoryId} updated", storyId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryUpdated", story);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryUpdated", story);
|
||||
}
|
||||
|
||||
public async Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying story {StoryId} deleted", storyId);
|
||||
logger.LogInformation("Notifying story {StoryId} deleted", storyId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryDeleted", new { StoryId = storyId });
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryDeleted", new { StoryId = storyId });
|
||||
}
|
||||
|
||||
// Task notifications
|
||||
@@ -125,37 +115,37 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying task {TaskId} created in story {StoryId}", taskId, storyId);
|
||||
logger.LogInformation("Notifying task {TaskId} created in story {StoryId}", taskId, storyId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskCreated", task);
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("TaskCreated", task);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskCreated", task);
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("TaskCreated", task);
|
||||
}
|
||||
|
||||
public async Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying task {TaskId} updated", taskId);
|
||||
logger.LogInformation("Notifying task {TaskId} updated", taskId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskUpdated", task);
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskUpdated", task);
|
||||
}
|
||||
|
||||
public async Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying task {TaskId} deleted", taskId);
|
||||
logger.LogInformation("Notifying task {TaskId} deleted", taskId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskDeleted", new { TaskId = taskId });
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskDeleted", new { TaskId = taskId });
|
||||
}
|
||||
|
||||
public async Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId)
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying task {TaskId} assigned to {AssigneeId}", taskId, assigneeId);
|
||||
logger.LogInformation("Notifying task {TaskId} assigned to {AssigneeId}", taskId, assigneeId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskAssigned", new
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskAssigned", new
|
||||
{
|
||||
TaskId = taskId,
|
||||
AssigneeId = assigneeId,
|
||||
@@ -167,21 +157,21 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
{
|
||||
var groupName = $"project-{projectId}";
|
||||
|
||||
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueCreated", issue);
|
||||
await projectHubContext.Clients.Group(groupName).SendAsync("IssueCreated", issue);
|
||||
}
|
||||
|
||||
public async Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue)
|
||||
{
|
||||
var groupName = $"project-{projectId}";
|
||||
|
||||
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueUpdated", issue);
|
||||
await projectHubContext.Clients.Group(groupName).SendAsync("IssueUpdated", issue);
|
||||
}
|
||||
|
||||
public async Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId)
|
||||
{
|
||||
var groupName = $"project-{projectId}";
|
||||
|
||||
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueDeleted", new { IssueId = issueId });
|
||||
await projectHubContext.Clients.Group(groupName).SendAsync("IssueDeleted", new { IssueId = issueId });
|
||||
}
|
||||
|
||||
public async Task NotifyIssueStatusChanged(
|
||||
@@ -193,7 +183,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
{
|
||||
var groupName = $"project-{projectId}";
|
||||
|
||||
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueStatusChanged", new
|
||||
await projectHubContext.Clients.Group(groupName).SendAsync("IssueStatusChanged", new
|
||||
{
|
||||
IssueId = issueId,
|
||||
OldStatus = oldStatus,
|
||||
@@ -208,9 +198,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying sprint {SprintId} created in project {ProjectId}", sprintId, projectId);
|
||||
logger.LogInformation("Notifying sprint {SprintId} created in project {ProjectId}", sprintId, projectId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCreated", new
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCreated", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -218,7 +208,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCreated", new
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCreated", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -231,9 +221,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
{
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
|
||||
_logger.LogInformation("Notifying sprint {SprintId} updated", sprintId);
|
||||
logger.LogInformation("Notifying sprint {SprintId} updated", sprintId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintUpdated", new
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintUpdated", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -247,9 +237,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying sprint {SprintId} started", sprintId);
|
||||
logger.LogInformation("Notifying sprint {SprintId} started", sprintId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintStarted", new
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintStarted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -257,7 +247,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintStarted", new
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintStarted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -271,9 +261,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying sprint {SprintId} completed", sprintId);
|
||||
logger.LogInformation("Notifying sprint {SprintId} completed", sprintId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCompleted", new
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCompleted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -281,7 +271,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCompleted", new
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCompleted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -295,9 +285,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
var projectGroupName = $"project-{projectId}";
|
||||
var tenantGroupName = $"tenant-{tenantId}";
|
||||
|
||||
_logger.LogInformation("Notifying sprint {SprintId} deleted", sprintId);
|
||||
logger.LogInformation("Notifying sprint {SprintId} deleted", sprintId);
|
||||
|
||||
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintDeleted", new
|
||||
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintDeleted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -305,7 +295,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintDeleted", new
|
||||
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintDeleted", new
|
||||
{
|
||||
SprintId = sprintId,
|
||||
SprintName = sprintName,
|
||||
@@ -318,7 +308,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
{
|
||||
var userConnectionId = $"user-{userId}";
|
||||
|
||||
await _notificationHubContext.Clients.User(userId.ToString()).SendAsync("Notification", new
|
||||
await notificationHubContext.Clients.User(userId.ToString()).SendAsync("Notification", new
|
||||
{
|
||||
Message = message,
|
||||
Type = type,
|
||||
@@ -330,7 +320,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
|
||||
{
|
||||
var groupName = $"tenant-{tenantId}";
|
||||
|
||||
await _notificationHubContext.Clients.Group(groupName).SendAsync("Notification", new
|
||||
await notificationHubContext.Clients.Group(groupName).SendAsync("Notification", new
|
||||
{
|
||||
Message = message,
|
||||
Type = type,
|
||||
|
||||
@@ -8,38 +8,22 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
|
||||
|
||||
public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCommand, Guid>
|
||||
public class AcceptInvitationCommandHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
IUserRepository userRepository,
|
||||
IUserTenantRoleRepository userTenantRoleRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IPasswordHasher passwordHasher,
|
||||
ILogger<AcceptInvitationCommandHandler> logger)
|
||||
: IRequestHandler<AcceptInvitationCommand, Guid>
|
||||
{
|
||||
private readonly IInvitationRepository _invitationRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly ILogger<AcceptInvitationCommandHandler> _logger;
|
||||
|
||||
public AcceptInvitationCommandHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
IUserRepository userRepository,
|
||||
IUserTenantRoleRepository userTenantRoleRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IPasswordHasher passwordHasher,
|
||||
ILogger<AcceptInvitationCommandHandler> logger)
|
||||
{
|
||||
_invitationRepository = invitationRepository;
|
||||
_userRepository = userRepository;
|
||||
_userTenantRoleRepository = userTenantRoleRepository;
|
||||
_tokenService = tokenService;
|
||||
_passwordHasher = passwordHasher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(AcceptInvitationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Hash the token to find the invitation
|
||||
var tokenHash = _tokenService.HashToken(request.Token);
|
||||
var tokenHash = tokenService.HashToken(request.Token);
|
||||
|
||||
// Find invitation by token hash
|
||||
var invitation = await _invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
var invitation = await invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
if (invitation == null)
|
||||
throw new InvalidOperationException("Invalid invitation token");
|
||||
|
||||
@@ -50,14 +34,14 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
|
||||
var fullName = FullName.Create(request.FullName);
|
||||
|
||||
// Check if user already exists in this tenant
|
||||
var existingUser = await _userRepository.GetByEmailAsync(invitation.TenantId, email, cancellationToken);
|
||||
var existingUser = await userRepository.GetByEmailAsync(invitation.TenantId, email, cancellationToken);
|
||||
|
||||
User user;
|
||||
if (existingUser != null)
|
||||
{
|
||||
// User already exists in this tenant
|
||||
user = existingUser;
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"User {UserId} already exists in tenant {TenantId}, adding role",
|
||||
user.Id,
|
||||
invitation.TenantId);
|
||||
@@ -65,16 +49,16 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
|
||||
else
|
||||
{
|
||||
// Create new user
|
||||
var passwordHash = _passwordHasher.HashPassword(request.Password);
|
||||
var passwordHash = passwordHasher.HashPassword(request.Password);
|
||||
user = User.CreateLocal(
|
||||
invitation.TenantId,
|
||||
email,
|
||||
passwordHash,
|
||||
fullName);
|
||||
|
||||
await _userRepository.AddAsync(user, cancellationToken);
|
||||
await userRepository.AddAsync(user, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Created new user {UserId} for invitation acceptance in tenant {TenantId}",
|
||||
user.Id,
|
||||
invitation.TenantId);
|
||||
@@ -82,7 +66,7 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
|
||||
|
||||
// Check if user already has a role in this tenant
|
||||
var userId = UserId.Create(user.Id);
|
||||
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||
var existingRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||
userId,
|
||||
invitation.TenantId,
|
||||
cancellationToken);
|
||||
@@ -91,9 +75,9 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
|
||||
{
|
||||
// User already has a role - update it
|
||||
existingRole.UpdateRole(invitation.Role, user.Id);
|
||||
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
|
||||
await userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Updated role for user {UserId} in tenant {TenantId} to {Role}",
|
||||
user.Id,
|
||||
invitation.TenantId,
|
||||
@@ -108,9 +92,9 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
|
||||
invitation.Role,
|
||||
invitation.InvitedBy);
|
||||
|
||||
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
|
||||
await userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Created role mapping for user {UserId} in tenant {TenantId} with role {Role}",
|
||||
user.Id,
|
||||
invitation.TenantId,
|
||||
@@ -119,9 +103,9 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
|
||||
|
||||
// Mark invitation as accepted
|
||||
invitation.Accept();
|
||||
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
|
||||
await invitationRepository.UpdateAsync(invitation, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Invitation {InvitationId} accepted by user {UserId}",
|
||||
invitation.Id,
|
||||
user.Id);
|
||||
|
||||
@@ -6,26 +6,18 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
|
||||
|
||||
public class CancelInvitationCommandHandler : IRequestHandler<CancelInvitationCommand, Unit>
|
||||
public class CancelInvitationCommandHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
ILogger<CancelInvitationCommandHandler> logger)
|
||||
: IRequestHandler<CancelInvitationCommand, Unit>
|
||||
{
|
||||
private readonly IInvitationRepository _invitationRepository;
|
||||
private readonly ILogger<CancelInvitationCommandHandler> _logger;
|
||||
|
||||
public CancelInvitationCommandHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
ILogger<CancelInvitationCommandHandler> logger)
|
||||
{
|
||||
_invitationRepository = invitationRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(CancelInvitationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var invitationId = InvitationId.Create(request.InvitationId);
|
||||
var tenantId = TenantId.Create(request.TenantId);
|
||||
|
||||
// Get the invitation
|
||||
var invitation = await _invitationRepository.GetByIdAsync(invitationId, cancellationToken);
|
||||
var invitation = await invitationRepository.GetByIdAsync(invitationId, cancellationToken);
|
||||
if (invitation == null)
|
||||
throw new InvalidOperationException($"Invitation {request.InvitationId} not found");
|
||||
|
||||
@@ -35,9 +27,9 @@ public class CancelInvitationCommandHandler : IRequestHandler<CancelInvitationCo
|
||||
|
||||
// Cancel the invitation
|
||||
invitation.Cancel();
|
||||
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
|
||||
await invitationRepository.UpdateAsync(invitation, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Invitation {InvitationId} cancelled by user {CancelledBy} in tenant {TenantId}",
|
||||
request.InvitationId,
|
||||
request.CancelledBy,
|
||||
|
||||
@@ -9,42 +9,22 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
|
||||
|
||||
public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordCommand, Unit>
|
||||
public class ForgotPasswordCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IPasswordResetTokenRepository tokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService emailTemplateService,
|
||||
IRateLimitService rateLimitService,
|
||||
ILogger<ForgotPasswordCommandHandler> logger)
|
||||
: IRequestHandler<ForgotPasswordCommand, Unit>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ITenantRepository _tenantRepository;
|
||||
private readonly IPasswordResetTokenRepository _tokenRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IEmailTemplateService _emailTemplateService;
|
||||
private readonly IRateLimitService _rateLimitService;
|
||||
private readonly ILogger<ForgotPasswordCommandHandler> _logger;
|
||||
|
||||
public ForgotPasswordCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IPasswordResetTokenRepository tokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService emailTemplateService,
|
||||
IRateLimitService rateLimitService,
|
||||
ILogger<ForgotPasswordCommandHandler> logger)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tenantRepository = tenantRepository;
|
||||
_tokenRepository = tokenRepository;
|
||||
_tokenService = tokenService;
|
||||
_emailService = emailService;
|
||||
_emailTemplateService = emailTemplateService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ForgotPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Rate limiting: 3 requests per hour per email
|
||||
var rateLimitKey = $"forgot-password:{request.Email.ToLowerInvariant()}";
|
||||
var isAllowed = await _rateLimitService.IsAllowedAsync(
|
||||
var isAllowed = await rateLimitService.IsAllowedAsync(
|
||||
rateLimitKey,
|
||||
3,
|
||||
TimeSpan.FromHours(1),
|
||||
@@ -52,7 +32,7 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
|
||||
|
||||
if (!isAllowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Rate limit exceeded for forgot password. Email: {Email}, IP: {IpAddress}",
|
||||
request.Email,
|
||||
request.IpAddress);
|
||||
@@ -69,15 +49,15 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogWarning("Invalid tenant slug: {TenantSlug} - {Error}", request.TenantSlug, ex.Message);
|
||||
logger.LogWarning("Invalid tenant slug: {TenantSlug} - {Error}", request.TenantSlug, ex.Message);
|
||||
// Return success to prevent enumeration
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
var tenant = await _tenantRepository.GetBySlugAsync(tenantSlug, cancellationToken);
|
||||
var tenant = await tenantRepository.GetBySlugAsync(tenantSlug, cancellationToken);
|
||||
if (tenant == null)
|
||||
{
|
||||
_logger.LogWarning("Tenant not found: {TenantSlug}", request.TenantSlug);
|
||||
logger.LogWarning("Tenant not found: {TenantSlug}", request.TenantSlug);
|
||||
// Return success to prevent enumeration
|
||||
return Unit.Value;
|
||||
}
|
||||
@@ -90,15 +70,15 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogWarning("Invalid email: {Email} - {Error}", request.Email, ex.Message);
|
||||
logger.LogWarning("Invalid email: {Email} - {Error}", request.Email, ex.Message);
|
||||
// Return success to prevent enumeration
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
|
||||
var user = await userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"User not found for password reset. Email: {Email}, Tenant: {TenantSlug}",
|
||||
request.Email,
|
||||
request.TenantSlug);
|
||||
@@ -108,11 +88,11 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
|
||||
}
|
||||
|
||||
// Invalidate all existing password reset tokens for this user
|
||||
await _tokenRepository.InvalidateAllForUserAsync(UserId.Create(user.Id), cancellationToken);
|
||||
await tokenRepository.InvalidateAllForUserAsync(UserId.Create(user.Id), cancellationToken);
|
||||
|
||||
// Generate new password reset token (1-hour expiration)
|
||||
var token = _tokenService.GenerateToken();
|
||||
var tokenHash = _tokenService.HashToken(token);
|
||||
var token = tokenService.GenerateToken();
|
||||
var tokenHash = tokenService.HashToken(token);
|
||||
var expiresAt = DateTime.UtcNow.AddHours(1);
|
||||
|
||||
var resetToken = PasswordResetToken.Create(
|
||||
@@ -122,13 +102,13 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
|
||||
request.IpAddress,
|
||||
request.UserAgent);
|
||||
|
||||
await _tokenRepository.AddAsync(resetToken, cancellationToken);
|
||||
await tokenRepository.AddAsync(resetToken, cancellationToken);
|
||||
|
||||
// Construct reset URL
|
||||
var resetUrl = $"{request.BaseUrl}/reset-password?token={token}";
|
||||
|
||||
// Send password reset email
|
||||
var emailBody = _emailTemplateService.RenderPasswordResetEmail(
|
||||
var emailBody = emailTemplateService.RenderPasswordResetEmail(
|
||||
user.FullName.ToString(),
|
||||
resetUrl);
|
||||
|
||||
@@ -138,18 +118,18 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
|
||||
HtmlBody: emailBody
|
||||
);
|
||||
|
||||
var emailSent = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
var emailSent = await emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
|
||||
if (emailSent)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Password reset email sent. UserId: {UserId}, Email: {Email}",
|
||||
user.Id,
|
||||
user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
logger.LogError(
|
||||
"Failed to send password reset email. UserId: {UserId}, Email: {Email}",
|
||||
user.Id,
|
||||
user.Email);
|
||||
|
||||
@@ -9,37 +9,17 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser;
|
||||
|
||||
public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
|
||||
public class InviteUserCommandHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
IUserRepository userRepository,
|
||||
IUserTenantRoleRepository userTenantRoleRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService templateService,
|
||||
ILogger<InviteUserCommandHandler> logger)
|
||||
: IRequestHandler<InviteUserCommand, Guid>
|
||||
{
|
||||
private readonly IInvitationRepository _invitationRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
||||
private readonly ITenantRepository _tenantRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IEmailTemplateService _templateService;
|
||||
private readonly ILogger<InviteUserCommandHandler> _logger;
|
||||
|
||||
public InviteUserCommandHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
IUserRepository userRepository,
|
||||
IUserTenantRoleRepository userTenantRoleRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService templateService,
|
||||
ILogger<InviteUserCommandHandler> logger)
|
||||
{
|
||||
_invitationRepository = invitationRepository;
|
||||
_userRepository = userRepository;
|
||||
_userTenantRoleRepository = userTenantRoleRepository;
|
||||
_tenantRepository = tenantRepository;
|
||||
_tokenService = tokenService;
|
||||
_emailService = emailService;
|
||||
_templateService = templateService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(InviteUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = TenantId.Create(request.TenantId);
|
||||
@@ -50,23 +30,23 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
|
||||
throw new ArgumentException($"Invalid role: {request.Role}");
|
||||
|
||||
// Check if tenant exists
|
||||
var tenant = await _tenantRepository.GetByIdAsync(tenantId, cancellationToken);
|
||||
var tenant = await tenantRepository.GetByIdAsync(tenantId, cancellationToken);
|
||||
if (tenant == null)
|
||||
throw new InvalidOperationException($"Tenant {request.TenantId} not found");
|
||||
|
||||
// Check if inviter exists
|
||||
var inviter = await _userRepository.GetByIdAsync(invitedBy, cancellationToken);
|
||||
var inviter = await userRepository.GetByIdAsync(invitedBy, cancellationToken);
|
||||
if (inviter == null)
|
||||
throw new InvalidOperationException($"Inviter user {request.InvitedBy} not found");
|
||||
|
||||
var email = Email.Create(request.Email);
|
||||
|
||||
// Check if user already exists in this tenant
|
||||
var existingUser = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
|
||||
var existingUser = await userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
|
||||
if (existingUser != null)
|
||||
{
|
||||
// Check if user already has a role in this tenant
|
||||
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||
var existingRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||
UserId.Create(existingUser.Id),
|
||||
tenantId,
|
||||
cancellationToken);
|
||||
@@ -76,7 +56,7 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
|
||||
}
|
||||
|
||||
// Check for existing pending invitation
|
||||
var existingInvitation = await _invitationRepository.GetPendingByEmailAndTenantAsync(
|
||||
var existingInvitation = await invitationRepository.GetPendingByEmailAndTenantAsync(
|
||||
request.Email,
|
||||
tenantId,
|
||||
cancellationToken);
|
||||
@@ -85,8 +65,8 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
|
||||
throw new InvalidOperationException($"A pending invitation already exists for {request.Email} in this tenant");
|
||||
|
||||
// Generate secure token
|
||||
var token = _tokenService.GenerateToken();
|
||||
var tokenHash = _tokenService.HashToken(token);
|
||||
var token = tokenService.GenerateToken();
|
||||
var tokenHash = tokenService.HashToken(token);
|
||||
|
||||
// Create invitation
|
||||
var invitation = Invitation.Create(
|
||||
@@ -96,11 +76,11 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
|
||||
tokenHash,
|
||||
invitedBy);
|
||||
|
||||
await _invitationRepository.AddAsync(invitation, cancellationToken);
|
||||
await invitationRepository.AddAsync(invitation, cancellationToken);
|
||||
|
||||
// Send invitation email
|
||||
var invitationLink = $"{request.BaseUrl}/accept-invitation?token={token}";
|
||||
var htmlBody = _templateService.RenderInvitationEmail(
|
||||
var htmlBody = templateService.RenderInvitationEmail(
|
||||
recipientName: request.Email.Split('@')[0], // Use email prefix as fallback name
|
||||
tenantName: tenant.Name.Value,
|
||||
inviterName: inviter.FullName.Value,
|
||||
@@ -112,18 +92,18 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
|
||||
HtmlBody: htmlBody,
|
||||
PlainTextBody: $"You've been invited to join {tenant.Name.Value}. Click here to accept: {invitationLink}");
|
||||
|
||||
var emailSuccess = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
var emailSuccess = await emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
|
||||
if (!emailSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Failed to send invitation email to {Email} for tenant {TenantId}",
|
||||
request.Email,
|
||||
request.TenantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Invitation sent to {Email} for tenant {TenantId} with role {Role}",
|
||||
request.Email,
|
||||
request.TenantId,
|
||||
|
||||
@@ -16,34 +16,16 @@ namespace ColaFlow.Modules.Identity.Application.Commands.ResendVerificationEmail
|
||||
/// - Rate limiting (1 email per minute)
|
||||
/// - Token rotation (invalidate old token)
|
||||
/// </summary>
|
||||
public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerificationEmailCommand, bool>
|
||||
public class ResendVerificationEmailCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IEmailVerificationTokenRepository tokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService templateService,
|
||||
IRateLimitService rateLimitService,
|
||||
ILogger<ResendVerificationEmailCommandHandler> logger)
|
||||
: IRequestHandler<ResendVerificationEmailCommand, bool>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IEmailVerificationTokenRepository _tokenRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IEmailTemplateService _templateService;
|
||||
private readonly IRateLimitService _rateLimitService;
|
||||
private readonly ILogger<ResendVerificationEmailCommandHandler> _logger;
|
||||
|
||||
public ResendVerificationEmailCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IEmailVerificationTokenRepository tokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService templateService,
|
||||
IRateLimitService rateLimitService,
|
||||
ILogger<ResendVerificationEmailCommandHandler> logger)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenRepository = tokenRepository;
|
||||
_tokenService = tokenService;
|
||||
_emailService = emailService;
|
||||
_templateService = templateService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(ResendVerificationEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -51,25 +33,25 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
|
||||
// 1. Find user by email and tenant (no enumeration - don't reveal if user exists)
|
||||
var email = Email.Create(request.Email);
|
||||
var tenantId = TenantId.Create(request.TenantId);
|
||||
var user = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
|
||||
var user = await userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
// Email enumeration prevention: Don't reveal user doesn't exist
|
||||
_logger.LogWarning("Resend verification requested for non-existent email: {Email}", request.Email);
|
||||
logger.LogWarning("Resend verification requested for non-existent email: {Email}", request.Email);
|
||||
return true; // Always return success
|
||||
}
|
||||
|
||||
// 2. Check if already verified (success if so)
|
||||
if (user.IsEmailVerified)
|
||||
{
|
||||
_logger.LogInformation("Email already verified for user {UserId}", user.Id);
|
||||
logger.LogInformation("Email already verified for user {UserId}", user.Id);
|
||||
return true; // Already verified - success
|
||||
}
|
||||
|
||||
// 3. Check rate limit (1 email per minute per address)
|
||||
var rateLimitKey = $"resend-verification:{request.Email}:{request.TenantId}";
|
||||
var isAllowed = await _rateLimitService.IsAllowedAsync(
|
||||
var isAllowed = await rateLimitService.IsAllowedAsync(
|
||||
rateLimitKey,
|
||||
maxAttempts: 1,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
@@ -77,15 +59,15 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
|
||||
|
||||
if (!isAllowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Rate limit exceeded for resend verification: {Email}",
|
||||
request.Email);
|
||||
return true; // Still return success to prevent enumeration
|
||||
}
|
||||
|
||||
// 4. Generate new verification token with SHA-256 hashing
|
||||
var token = _tokenService.GenerateToken();
|
||||
var tokenHash = _tokenService.HashToken(token);
|
||||
var token = tokenService.GenerateToken();
|
||||
var tokenHash = tokenService.HashToken(token);
|
||||
|
||||
// 5. Invalidate old tokens by creating new one (token rotation)
|
||||
var verificationToken = EmailVerificationToken.Create(
|
||||
@@ -93,11 +75,11 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
|
||||
tokenHash,
|
||||
DateTime.UtcNow.AddHours(24)); // 24 hours expiration
|
||||
|
||||
await _tokenRepository.AddAsync(verificationToken, cancellationToken);
|
||||
await tokenRepository.AddAsync(verificationToken, cancellationToken);
|
||||
|
||||
// 6. Send verification email
|
||||
var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
|
||||
var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
|
||||
var htmlBody = templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
|
||||
|
||||
var emailMessage = new EmailMessage(
|
||||
To: request.Email,
|
||||
@@ -105,18 +87,18 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
|
||||
HtmlBody: htmlBody,
|
||||
PlainTextBody: $"Click the link to verify your email: {verificationLink}");
|
||||
|
||||
var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
var success = await emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Failed to send verification email to {Email} for user {UserId}",
|
||||
request.Email,
|
||||
user.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Verification email resent to {Email} for user {UserId}",
|
||||
request.Email,
|
||||
user.Id);
|
||||
@@ -127,7 +109,7 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Error resending verification email for {Email}",
|
||||
request.Email);
|
||||
|
||||
@@ -6,56 +6,38 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
|
||||
|
||||
public class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand, bool>
|
||||
public class ResetPasswordCommandHandler(
|
||||
IPasswordResetTokenRepository tokenRepository,
|
||||
IUserRepository userRepository,
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IPasswordHasher passwordHasher,
|
||||
ILogger<ResetPasswordCommandHandler> logger,
|
||||
IPublisher publisher)
|
||||
: IRequestHandler<ResetPasswordCommand, bool>
|
||||
{
|
||||
private readonly IPasswordResetTokenRepository _tokenRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly ILogger<ResetPasswordCommandHandler> _logger;
|
||||
private readonly IPublisher _publisher;
|
||||
|
||||
public ResetPasswordCommandHandler(
|
||||
IPasswordResetTokenRepository tokenRepository,
|
||||
IUserRepository userRepository,
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IPasswordHasher passwordHasher,
|
||||
ILogger<ResetPasswordCommandHandler> logger,
|
||||
IPublisher publisher)
|
||||
{
|
||||
_tokenRepository = tokenRepository;
|
||||
_userRepository = userRepository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_tokenService = tokenService;
|
||||
_passwordHasher = passwordHasher;
|
||||
_logger = logger;
|
||||
_publisher = publisher;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(ResetPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate new password
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||
{
|
||||
_logger.LogWarning("Invalid password provided for reset");
|
||||
logger.LogWarning("Invalid password provided for reset");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash the token to look it up
|
||||
var tokenHash = _tokenService.HashToken(request.Token);
|
||||
var resetToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
var tokenHash = tokenService.HashToken(request.Token);
|
||||
var resetToken = await tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
|
||||
if (resetToken == null)
|
||||
{
|
||||
_logger.LogWarning("Password reset token not found");
|
||||
logger.LogWarning("Password reset token not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resetToken.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Password reset token is invalid. IsExpired: {IsExpired}, IsUsed: {IsUsed}",
|
||||
resetToken.IsExpired,
|
||||
resetToken.IsUsed);
|
||||
@@ -63,36 +45,36 @@ public class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand,
|
||||
}
|
||||
|
||||
// Get user
|
||||
var user = await _userRepository.GetByIdAsync(resetToken.UserId, cancellationToken);
|
||||
var user = await userRepository.GetByIdAsync(resetToken.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogError("User {UserId} not found for password reset", resetToken.UserId);
|
||||
logger.LogError("User {UserId} not found for password reset", resetToken.UserId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
var newPasswordHash = _passwordHasher.HashPassword(request.NewPassword);
|
||||
var newPasswordHash = passwordHasher.HashPassword(request.NewPassword);
|
||||
|
||||
// Update user password (will emit UserPasswordChangedEvent)
|
||||
user.UpdatePassword(newPasswordHash);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
await userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
// Mark token as used
|
||||
resetToken.MarkAsUsed();
|
||||
await _tokenRepository.UpdateAsync(resetToken, cancellationToken);
|
||||
await tokenRepository.UpdateAsync(resetToken, cancellationToken);
|
||||
|
||||
// Revoke all refresh tokens for security (force re-login on all devices)
|
||||
await _refreshTokenRepository.RevokeAllUserTokensAsync(
|
||||
await refreshTokenRepository.RevokeAllUserTokensAsync(
|
||||
(Guid)user.Id,
|
||||
"Password reset",
|
||||
cancellationToken);
|
||||
|
||||
// Publish domain event for audit logging
|
||||
await _publisher.Publish(
|
||||
await publisher.Publish(
|
||||
new PasswordResetCompletedEvent((Guid)user.Id, resetToken.IpAddress),
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Password reset successfully completed for user {UserId}. All refresh tokens revoked.",
|
||||
(Guid)user.Id);
|
||||
|
||||
|
||||
@@ -8,52 +8,36 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
|
||||
|
||||
public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificationEmailCommand, Unit>
|
||||
public class SendVerificationEmailCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IEmailVerificationTokenRepository tokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService templateService,
|
||||
ILogger<SendVerificationEmailCommandHandler> logger)
|
||||
: IRequestHandler<SendVerificationEmailCommand, Unit>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IEmailVerificationTokenRepository _tokenRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IEmailTemplateService _templateService;
|
||||
private readonly ILogger<SendVerificationEmailCommandHandler> _logger;
|
||||
|
||||
public SendVerificationEmailCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IEmailVerificationTokenRepository tokenRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IEmailTemplateService templateService,
|
||||
ILogger<SendVerificationEmailCommandHandler> logger)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenRepository = tokenRepository;
|
||||
_tokenService = tokenService;
|
||||
_emailService = emailService;
|
||||
_templateService = templateService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = UserId.Create(request.UserId);
|
||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||
var user = await userRepository.GetByIdAsync(userId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
|
||||
logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
// If already verified, no need to send email
|
||||
if (user.IsEmailVerified)
|
||||
{
|
||||
_logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
|
||||
logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
// Generate token
|
||||
var token = _tokenService.GenerateToken();
|
||||
var tokenHash = _tokenService.HashToken(token);
|
||||
var token = tokenService.GenerateToken();
|
||||
var tokenHash = tokenService.HashToken(token);
|
||||
|
||||
// Create verification token entity
|
||||
var verificationToken = EmailVerificationToken.Create(
|
||||
@@ -61,11 +45,11 @@ public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificat
|
||||
tokenHash,
|
||||
DateTime.UtcNow.AddHours(24));
|
||||
|
||||
await _tokenRepository.AddAsync(verificationToken, cancellationToken);
|
||||
await tokenRepository.AddAsync(verificationToken, cancellationToken);
|
||||
|
||||
// Send email (non-blocking)
|
||||
var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
|
||||
var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
|
||||
var htmlBody = templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
|
||||
|
||||
var emailMessage = new EmailMessage(
|
||||
To: request.Email,
|
||||
@@ -73,18 +57,18 @@ public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificat
|
||||
HtmlBody: htmlBody,
|
||||
PlainTextBody: $"Click the link to verify your email: {verificationLink}");
|
||||
|
||||
var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
var success = await emailService.SendEmailAsync(emailMessage, cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Failed to send verification email to {Email} for user {UserId}",
|
||||
request.Email,
|
||||
request.UserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Verification email sent to {Email} for user {UserId}",
|
||||
request.Email,
|
||||
request.UserId);
|
||||
|
||||
@@ -5,40 +5,28 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
|
||||
|
||||
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
|
||||
public class VerifyEmailCommandHandler(
|
||||
IEmailVerificationTokenRepository tokenRepository,
|
||||
IUserRepository userRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
ILogger<VerifyEmailCommandHandler> logger)
|
||||
: IRequestHandler<VerifyEmailCommand, bool>
|
||||
{
|
||||
private readonly IEmailVerificationTokenRepository _tokenRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ISecurityTokenService _tokenService;
|
||||
private readonly ILogger<VerifyEmailCommandHandler> _logger;
|
||||
|
||||
public VerifyEmailCommandHandler(
|
||||
IEmailVerificationTokenRepository tokenRepository,
|
||||
IUserRepository userRepository,
|
||||
ISecurityTokenService tokenService,
|
||||
ILogger<VerifyEmailCommandHandler> logger)
|
||||
{
|
||||
_tokenRepository = tokenRepository;
|
||||
_userRepository = userRepository;
|
||||
_tokenService = tokenService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Hash the token to look it up
|
||||
var tokenHash = _tokenService.HashToken(request.Token);
|
||||
var verificationToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
var tokenHash = tokenService.HashToken(request.Token);
|
||||
var verificationToken = await tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
|
||||
if (verificationToken == null)
|
||||
{
|
||||
_logger.LogWarning("Email verification token not found");
|
||||
logger.LogWarning("Email verification token not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verificationToken.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Email verification token is invalid. IsExpired: {IsExpired}, IsVerified: {IsVerified}",
|
||||
verificationToken.IsExpired,
|
||||
verificationToken.IsVerified);
|
||||
@@ -46,22 +34,22 @@ public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, boo
|
||||
}
|
||||
|
||||
// Get user and mark email as verified
|
||||
var user = await _userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
|
||||
var user = await userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
|
||||
logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark token as verified
|
||||
verificationToken.MarkAsVerified();
|
||||
await _tokenRepository.UpdateAsync(verificationToken, cancellationToken);
|
||||
await tokenRepository.UpdateAsync(verificationToken, cancellationToken);
|
||||
|
||||
// Mark user email as verified (will emit domain event)
|
||||
user.VerifyEmail();
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
await userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Email verified for user {UserId}", user.Id);
|
||||
logger.LogInformation("Email verified for user {UserId}", user.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,18 +7,12 @@ namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler for InvitationAcceptedEvent - logs acceptance
|
||||
/// </summary>
|
||||
public class InvitationAcceptedEventHandler : INotificationHandler<InvitationAcceptedEvent>
|
||||
public class InvitationAcceptedEventHandler(ILogger<InvitationAcceptedEventHandler> logger)
|
||||
: INotificationHandler<InvitationAcceptedEvent>
|
||||
{
|
||||
private readonly ILogger<InvitationAcceptedEventHandler> _logger;
|
||||
|
||||
public InvitationAcceptedEventHandler(ILogger<InvitationAcceptedEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(InvitationAcceptedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Invitation accepted: Email={Email}, Tenant={TenantId}, Role={Role}",
|
||||
notification.Email,
|
||||
notification.TenantId,
|
||||
|
||||
@@ -7,18 +7,12 @@ namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler for InvitationCancelledEvent - logs cancellation
|
||||
/// </summary>
|
||||
public class InvitationCancelledEventHandler : INotificationHandler<InvitationCancelledEvent>
|
||||
public class InvitationCancelledEventHandler(ILogger<InvitationCancelledEventHandler> logger)
|
||||
: INotificationHandler<InvitationCancelledEvent>
|
||||
{
|
||||
private readonly ILogger<InvitationCancelledEventHandler> _logger;
|
||||
|
||||
public InvitationCancelledEventHandler(ILogger<InvitationCancelledEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(InvitationCancelledEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Invitation cancelled: Email={Email}, Tenant={TenantId}",
|
||||
notification.Email,
|
||||
notification.TenantId);
|
||||
|
||||
@@ -7,18 +7,11 @@ namespace ColaFlow.Modules.Identity.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler for UserInvitedEvent - logs invitation
|
||||
/// </summary>
|
||||
public class UserInvitedEventHandler : INotificationHandler<UserInvitedEvent>
|
||||
public class UserInvitedEventHandler(ILogger<UserInvitedEventHandler> logger) : INotificationHandler<UserInvitedEvent>
|
||||
{
|
||||
private readonly ILogger<UserInvitedEventHandler> _logger;
|
||||
|
||||
public UserInvitedEventHandler(ILogger<UserInvitedEventHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task Handle(UserInvitedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"User invited: Email={Email}, Tenant={TenantId}, Role={Role}, InvitedBy={InvitedBy}",
|
||||
notification.Email,
|
||||
notification.TenantId,
|
||||
|
||||
@@ -6,34 +6,24 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
|
||||
|
||||
public class GetPendingInvitationsQueryHandler : IRequestHandler<GetPendingInvitationsQuery, List<InvitationDto>>
|
||||
public class GetPendingInvitationsQueryHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
IUserRepository userRepository,
|
||||
ILogger<GetPendingInvitationsQueryHandler> logger)
|
||||
: IRequestHandler<GetPendingInvitationsQuery, List<InvitationDto>>
|
||||
{
|
||||
private readonly IInvitationRepository _invitationRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ILogger<GetPendingInvitationsQueryHandler> _logger;
|
||||
|
||||
public GetPendingInvitationsQueryHandler(
|
||||
IInvitationRepository invitationRepository,
|
||||
IUserRepository userRepository,
|
||||
ILogger<GetPendingInvitationsQueryHandler> logger)
|
||||
{
|
||||
_invitationRepository = invitationRepository;
|
||||
_userRepository = userRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<InvitationDto>> Handle(GetPendingInvitationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = TenantId.Create(request.TenantId);
|
||||
|
||||
// Get all pending invitations for the tenant
|
||||
var invitations = await _invitationRepository.GetPendingByTenantAsync(tenantId, cancellationToken);
|
||||
var invitations = await invitationRepository.GetPendingByTenantAsync(tenantId, cancellationToken);
|
||||
|
||||
// Get all unique inviter user IDs
|
||||
var inviterIds = invitations.Select(i => (Guid)i.InvitedBy).Distinct().ToList();
|
||||
|
||||
// Fetch all inviters in one query
|
||||
var inviters = await _userRepository.GetByIdsAsync(inviterIds, cancellationToken);
|
||||
var inviters = await userRepository.GetByIdsAsync(inviterIds, cancellationToken);
|
||||
var inviterDict = inviters.ToDictionary(u => u.Id, u => u.FullName.Value);
|
||||
|
||||
// Map to DTOs
|
||||
@@ -47,7 +37,7 @@ public class GetPendingInvitationsQueryHandler : IRequestHandler<GetPendingInvit
|
||||
ExpiresAt: i.ExpiresAt
|
||||
)).ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Retrieved {Count} pending invitations for tenant {TenantId}",
|
||||
dtos.Count,
|
||||
request.TenantId);
|
||||
|
||||
@@ -11,19 +11,11 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||
/// Persists rate limit state in PostgreSQL to survive server restarts.
|
||||
/// Prevents email bombing attacks even after application restart.
|
||||
/// </summary>
|
||||
public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
public class DatabaseEmailRateLimiter(
|
||||
IdentityDbContext context,
|
||||
ILogger<DatabaseEmailRateLimiter> logger)
|
||||
: IRateLimitService
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
private readonly ILogger<DatabaseEmailRateLimiter> _logger;
|
||||
|
||||
public DatabaseEmailRateLimiter(
|
||||
IdentityDbContext context,
|
||||
ILogger<DatabaseEmailRateLimiter> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> IsAllowedAsync(
|
||||
string key,
|
||||
int maxAttempts,
|
||||
@@ -39,7 +31,7 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
var parts = key.Split(':');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
_logger.LogWarning("Invalid rate limit key format: {Key}. Expected format: 'operation:email:tenantId'", key);
|
||||
logger.LogWarning("Invalid rate limit key format: {Key}. Expected format: 'operation:email:tenantId'", key);
|
||||
return true; // Fail open (allow request) if key format is invalid
|
||||
}
|
||||
|
||||
@@ -49,12 +41,12 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
|
||||
if (!Guid.TryParse(tenantIdStr, out var tenantId))
|
||||
{
|
||||
_logger.LogWarning("Invalid tenant ID in rate limit key: {Key}", key);
|
||||
logger.LogWarning("Invalid tenant ID in rate limit key: {Key}", key);
|
||||
return true; // Fail open
|
||||
}
|
||||
|
||||
// Find existing rate limit record
|
||||
var rateLimit = await _context.EmailRateLimits
|
||||
var rateLimit = await context.EmailRateLimits
|
||||
.FirstOrDefaultAsync(
|
||||
r => r.Email == email &&
|
||||
r.TenantId == tenantId &&
|
||||
@@ -65,23 +57,23 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
if (rateLimit == null)
|
||||
{
|
||||
var newRateLimit = EmailRateLimit.Create(email, tenantId, operationType);
|
||||
_context.EmailRateLimits.Add(newRateLimit);
|
||||
context.EmailRateLimits.Add(newRateLimit);
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation(
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Rate limit record created for {Email} - {Operation} (Attempt 1/{MaxAttempts})",
|
||||
email, operationType, maxAttempts);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
// Handle race condition: another request created the record simultaneously
|
||||
_logger.LogWarning(ex,
|
||||
logger.LogWarning(ex,
|
||||
"Race condition detected while creating rate limit record for {Key}. Retrying...", key);
|
||||
|
||||
// Re-fetch the record created by the concurrent request
|
||||
rateLimit = await _context.EmailRateLimits
|
||||
rateLimit = await context.EmailRateLimits
|
||||
.FirstOrDefaultAsync(
|
||||
r => r.Email == email &&
|
||||
r.TenantId == tenantId &&
|
||||
@@ -90,7 +82,7 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
|
||||
if (rateLimit == null)
|
||||
{
|
||||
_logger.LogError("Failed to fetch rate limit record after race condition for {Key}", key);
|
||||
logger.LogError("Failed to fetch rate limit record after race condition for {Key}", key);
|
||||
return true; // Fail open
|
||||
}
|
||||
|
||||
@@ -106,10 +98,10 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
{
|
||||
// Window expired - reset counter and allow
|
||||
rateLimit.ResetAttempts();
|
||||
_context.EmailRateLimits.Update(rateLimit);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
context.EmailRateLimits.Update(rateLimit);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Rate limit window expired for {Email} - {Operation}. Counter reset (Attempt 1/{MaxAttempts})",
|
||||
email, operationType, maxAttempts);
|
||||
|
||||
@@ -122,7 +114,7 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
// Rate limit exceeded
|
||||
var remainingTime = window - (DateTime.UtcNow - rateLimit.LastSentAt);
|
||||
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Rate limit EXCEEDED for {Email} - {Operation}: {Attempts}/{MaxAttempts} attempts. " +
|
||||
"Retry after {RemainingSeconds} seconds",
|
||||
email, operationType, rateLimit.AttemptsCount, maxAttempts,
|
||||
@@ -133,10 +125,10 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
|
||||
// Still within limit - increment counter and allow
|
||||
rateLimit.RecordAttempt();
|
||||
_context.EmailRateLimits.Update(rateLimit);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
context.EmailRateLimits.Update(rateLimit);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Rate limit check passed for {Email} - {Operation} (Attempt {Attempts}/{MaxAttempts})",
|
||||
email, operationType, rateLimit.AttemptsCount, maxAttempts);
|
||||
|
||||
@@ -150,16 +142,16 @@ public class DatabaseEmailRateLimiter : IRateLimitService
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow - retentionPeriod;
|
||||
|
||||
var expiredRecords = await _context.EmailRateLimits
|
||||
var expiredRecords = await context.EmailRateLimits
|
||||
.Where(r => r.LastSentAt < cutoffDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (expiredRecords.Any())
|
||||
{
|
||||
_context.EmailRateLimits.RemoveRange(expiredRecords);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
context.EmailRateLimits.RemoveRange(expiredRecords);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Cleaned up {Count} expired rate limit records older than {CutoffDate}",
|
||||
expiredRecords.Count, cutoffDate);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||
/// In-memory rate limiting service implementation.
|
||||
/// For production, consider using Redis for distributed rate limiting.
|
||||
/// </summary>
|
||||
public class MemoryRateLimitService : IRateLimitService
|
||||
public class MemoryRateLimitService(IMemoryCache cache) : IRateLimitService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public MemoryRateLimitService(IMemoryCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public Task<bool> IsAllowedAsync(
|
||||
string key,
|
||||
int maxAttempts,
|
||||
@@ -25,7 +18,7 @@ public class MemoryRateLimitService : IRateLimitService
|
||||
var cacheKey = $"ratelimit:{key}";
|
||||
|
||||
// Get current attempt count from cache
|
||||
var attempts = _cache.GetOrCreate(cacheKey, entry =>
|
||||
var attempts = cache.GetOrCreate(cacheKey, entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = window;
|
||||
return 0;
|
||||
@@ -38,7 +31,7 @@ public class MemoryRateLimitService : IRateLimitService
|
||||
}
|
||||
|
||||
// Increment attempt count
|
||||
_cache.Set(cacheKey, attempts + 1, new MemoryCacheEntryOptions
|
||||
cache.Set(cacheKey, attempts + 1, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = window
|
||||
});
|
||||
|
||||
@@ -8,9 +8,8 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||
/// Mock email service for development/testing that logs emails instead of sending them
|
||||
/// Captures sent emails for testing purposes
|
||||
/// </summary>
|
||||
public sealed class MockEmailService : IEmailService
|
||||
public sealed class MockEmailService(ILogger<MockEmailService> logger) : IEmailService
|
||||
{
|
||||
private readonly ILogger<MockEmailService> _logger;
|
||||
private readonly List<EmailMessage> _sentEmails = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -18,23 +17,18 @@ public sealed class MockEmailService : IEmailService
|
||||
/// </summary>
|
||||
public IReadOnlyList<EmailMessage> SentEmails => _sentEmails.AsReadOnly();
|
||||
|
||||
public MockEmailService(ILogger<MockEmailService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Capture the email for testing
|
||||
_sentEmails.Add(message);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"[MOCK EMAIL] To: {To}, Subject: {Subject}, From: {From}",
|
||||
message.To,
|
||||
message.Subject,
|
||||
message.FromEmail ?? "default");
|
||||
|
||||
_logger.LogDebug(
|
||||
logger.LogDebug(
|
||||
"[MOCK EMAIL] HTML Body: {HtmlBody}",
|
||||
message.HtmlBody);
|
||||
|
||||
|
||||
@@ -10,31 +10,23 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
||||
/// <summary>
|
||||
/// SMTP-based email service for production use
|
||||
/// </summary>
|
||||
public sealed class SmtpEmailService : IEmailService
|
||||
public sealed class SmtpEmailService(
|
||||
ILogger<SmtpEmailService> logger,
|
||||
IConfiguration configuration)
|
||||
: IEmailService
|
||||
{
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SmtpEmailService(
|
||||
ILogger<SmtpEmailService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var smtpHost = _configuration["Email:Smtp:Host"];
|
||||
var smtpPort = int.Parse(_configuration["Email:Smtp:Port"] ?? "587");
|
||||
var smtpUsername = _configuration["Email:Smtp:Username"];
|
||||
var smtpPassword = _configuration["Email:Smtp:Password"];
|
||||
var enableSsl = bool.Parse(_configuration["Email:Smtp:EnableSsl"] ?? "true");
|
||||
var smtpHost = configuration["Email:Smtp:Host"];
|
||||
var smtpPort = int.Parse(configuration["Email:Smtp:Port"] ?? "587");
|
||||
var smtpUsername = configuration["Email:Smtp:Username"];
|
||||
var smtpPassword = configuration["Email:Smtp:Password"];
|
||||
var enableSsl = bool.Parse(configuration["Email:Smtp:EnableSsl"] ?? "true");
|
||||
|
||||
var defaultFromEmail = _configuration["Email:From"] ?? "noreply@colaflow.local";
|
||||
var defaultFromName = _configuration["Email:FromName"] ?? "ColaFlow";
|
||||
var defaultFromEmail = configuration["Email:From"] ?? "noreply@colaflow.local";
|
||||
var defaultFromName = configuration["Email:FromName"] ?? "ColaFlow";
|
||||
|
||||
using var smtpClient = new SmtpClient(smtpHost, smtpPort)
|
||||
{
|
||||
@@ -66,7 +58,7 @@ public sealed class SmtpEmailService : IEmailService
|
||||
|
||||
await smtpClient.SendMailAsync(mailMessage, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"Email sent successfully to {To} with subject: {Subject}",
|
||||
message.To,
|
||||
message.Subject);
|
||||
@@ -75,7 +67,7 @@ public sealed class SmtpEmailService : IEmailService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Failed to send email to {To} with subject: {Subject}",
|
||||
message.To,
|
||||
|
||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler that sends SignalR notifications when a PendingChange is applied
|
||||
/// </summary>
|
||||
public class PendingChangeAppliedNotificationHandler : INotificationHandler<PendingChangeAppliedEvent>
|
||||
public class PendingChangeAppliedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeAppliedNotificationHandler> logger)
|
||||
: INotificationHandler<PendingChangeAppliedEvent>
|
||||
{
|
||||
private readonly IMcpNotificationService _notificationService;
|
||||
private readonly ILogger<PendingChangeAppliedNotificationHandler> _logger;
|
||||
|
||||
public PendingChangeAppliedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeAppliedNotificationHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
private readonly ILogger<PendingChangeAppliedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task Handle(PendingChangeAppliedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -19,21 +19,15 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
/// Event handler for PendingChangeApprovedEvent
|
||||
/// Executes the approved change by dispatching appropriate commands
|
||||
/// </summary>
|
||||
public class PendingChangeApprovedEventHandler : INotificationHandler<PendingChangeApprovedEvent>
|
||||
public class PendingChangeApprovedEventHandler(
|
||||
IMediator mediator,
|
||||
IPendingChangeService pendingChangeService,
|
||||
ILogger<PendingChangeApprovedEventHandler> logger)
|
||||
: INotificationHandler<PendingChangeApprovedEvent>
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IPendingChangeService _pendingChangeService;
|
||||
private readonly ILogger<PendingChangeApprovedEventHandler> _logger;
|
||||
|
||||
public PendingChangeApprovedEventHandler(
|
||||
IMediator mediator,
|
||||
IPendingChangeService pendingChangeService,
|
||||
ILogger<PendingChangeApprovedEventHandler> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
private readonly ILogger<PendingChangeApprovedEventHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task Handle(PendingChangeApprovedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
/// Event handler that sends SignalR notifications when a PendingChange is approved
|
||||
/// Runs in parallel with PendingChangeApprovedEventHandler (which executes the change)
|
||||
/// </summary>
|
||||
public class PendingChangeApprovedNotificationHandler : INotificationHandler<PendingChangeApprovedEvent>
|
||||
public class PendingChangeApprovedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeApprovedNotificationHandler> logger)
|
||||
: INotificationHandler<PendingChangeApprovedEvent>
|
||||
{
|
||||
private readonly IMcpNotificationService _notificationService;
|
||||
private readonly ILogger<PendingChangeApprovedNotificationHandler> _logger;
|
||||
|
||||
public PendingChangeApprovedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeApprovedNotificationHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
private readonly ILogger<PendingChangeApprovedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task Handle(PendingChangeApprovedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -11,21 +11,15 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler that sends SignalR notifications when a PendingChange is created
|
||||
/// </summary>
|
||||
public class PendingChangeCreatedNotificationHandler : INotificationHandler<PendingChangeCreatedEvent>
|
||||
public class PendingChangeCreatedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
IPendingChangeRepository repository,
|
||||
ILogger<PendingChangeCreatedNotificationHandler> logger)
|
||||
: INotificationHandler<PendingChangeCreatedEvent>
|
||||
{
|
||||
private readonly IMcpNotificationService _notificationService;
|
||||
private readonly IPendingChangeRepository _repository;
|
||||
private readonly ILogger<PendingChangeCreatedNotificationHandler> _logger;
|
||||
|
||||
public PendingChangeCreatedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
IPendingChangeRepository repository,
|
||||
ILogger<PendingChangeCreatedNotificationHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
private readonly IPendingChangeRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
private readonly ILogger<PendingChangeCreatedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task Handle(PendingChangeCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler that sends SignalR notifications when a PendingChange expires
|
||||
/// </summary>
|
||||
public class PendingChangeExpiredNotificationHandler : INotificationHandler<PendingChangeExpiredEvent>
|
||||
public class PendingChangeExpiredNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeExpiredNotificationHandler> logger)
|
||||
: INotificationHandler<PendingChangeExpiredEvent>
|
||||
{
|
||||
private readonly IMcpNotificationService _notificationService;
|
||||
private readonly ILogger<PendingChangeExpiredNotificationHandler> _logger;
|
||||
|
||||
public PendingChangeExpiredNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeExpiredNotificationHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
private readonly ILogger<PendingChangeExpiredNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task Handle(PendingChangeExpiredEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Event handler that sends SignalR notifications when a PendingChange is rejected
|
||||
/// </summary>
|
||||
public class PendingChangeRejectedNotificationHandler : INotificationHandler<PendingChangeRejectedEvent>
|
||||
public class PendingChangeRejectedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeRejectedNotificationHandler> logger)
|
||||
: INotificationHandler<PendingChangeRejectedEvent>
|
||||
{
|
||||
private readonly IMcpNotificationService _notificationService;
|
||||
private readonly ILogger<PendingChangeRejectedNotificationHandler> _logger;
|
||||
|
||||
public PendingChangeRejectedNotificationHandler(
|
||||
IMcpNotificationService notificationService,
|
||||
ILogger<PendingChangeRejectedNotificationHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
private readonly ILogger<PendingChangeRejectedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task Handle(PendingChangeRejectedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -7,17 +7,10 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
/// <summary>
|
||||
/// Handler for the 'initialize' MCP method
|
||||
/// </summary>
|
||||
public class InitializeMethodHandler : IMcpMethodHandler
|
||||
public class InitializeMethodHandler(ILogger<InitializeMethodHandler> logger) : IMcpMethodHandler
|
||||
{
|
||||
private readonly ILogger<InitializeMethodHandler> _logger;
|
||||
|
||||
public string MethodName => "initialize";
|
||||
|
||||
public InitializeMethodHandler(ILogger<InitializeMethodHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -30,7 +23,7 @@ public class InitializeMethodHandler : IMcpMethodHandler
|
||||
initRequest = JsonSerializer.Deserialize<McpInitializeRequest>(json);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"MCP Initialize handshake received. Client: {ClientName} {ClientVersion}, Protocol: {ProtocolVersion}",
|
||||
initRequest?.ClientInfo?.Name ?? "Unknown",
|
||||
initRequest?.ClientInfo?.Version ?? "Unknown",
|
||||
@@ -39,7 +32,7 @@ public class InitializeMethodHandler : IMcpMethodHandler
|
||||
// Validate protocol version
|
||||
if (initRequest?.ProtocolVersion != "1.0")
|
||||
{
|
||||
_logger.LogWarning("Unsupported protocol version: {ProtocolVersion}", initRequest?.ProtocolVersion);
|
||||
logger.LogWarning("Unsupported protocol version: {ProtocolVersion}", initRequest?.ProtocolVersion);
|
||||
}
|
||||
|
||||
// Create initialize response
|
||||
@@ -54,13 +47,13 @@ public class InitializeMethodHandler : IMcpMethodHandler
|
||||
Capabilities = McpServerCapabilities.CreateDefault()
|
||||
};
|
||||
|
||||
_logger.LogInformation("MCP Initialize handshake completed successfully");
|
||||
logger.LogInformation("MCP Initialize handshake completed successfully");
|
||||
|
||||
return Task.FromResult<object?>(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling initialize request");
|
||||
logger.LogError(ex, "Error handling initialize request");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,18 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
/// Handler for 'resources/health' method
|
||||
/// Checks availability and health of all registered resources
|
||||
/// </summary>
|
||||
public class ResourceHealthCheckHandler : IMcpMethodHandler
|
||||
public class ResourceHealthCheckHandler(
|
||||
ILogger<ResourceHealthCheckHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
: IMcpMethodHandler
|
||||
{
|
||||
private readonly ILogger<ResourceHealthCheckHandler> _logger;
|
||||
private readonly IMcpResourceRegistry _resourceRegistry;
|
||||
|
||||
public string MethodName => "resources/health";
|
||||
|
||||
public ResourceHealthCheckHandler(
|
||||
ILogger<ResourceHealthCheckHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
{
|
||||
_logger = logger;
|
||||
_resourceRegistry = resourceRegistry;
|
||||
}
|
||||
|
||||
public async Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Handling resources/health request");
|
||||
logger.LogDebug("Handling resources/health request");
|
||||
|
||||
var resources = _resourceRegistry.GetAllResources();
|
||||
var resources = resourceRegistry.GetAllResources();
|
||||
var healthResults = new List<object>();
|
||||
var totalResources = resources.Count;
|
||||
var healthyResources = 0;
|
||||
@@ -67,7 +59,7 @@ public class ResourceHealthCheckHandler : IMcpMethodHandler
|
||||
catch (Exception ex)
|
||||
{
|
||||
unhealthyResources++;
|
||||
_logger.LogError(ex, "Health check failed for resource {ResourceType}", resource.GetType().Name);
|
||||
logger.LogError(ex, "Health check failed for resource {ResourceType}", resource.GetType().Name);
|
||||
|
||||
healthResults.Add(new
|
||||
{
|
||||
@@ -82,7 +74,7 @@ public class ResourceHealthCheckHandler : IMcpMethodHandler
|
||||
|
||||
var overallStatus = unhealthyResources == 0 ? "healthy" : "degraded";
|
||||
|
||||
_logger.LogInformation("Resource health check completed: {Healthy}/{Total} healthy",
|
||||
logger.LogInformation("Resource health check completed: {Healthy}/{Total} healthy",
|
||||
healthyResources, totalResources);
|
||||
|
||||
var response = new
|
||||
|
||||
@@ -7,30 +7,22 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
/// Handler for the 'resources/list' MCP method
|
||||
/// Returns categorized list of all available resources with full metadata
|
||||
/// </summary>
|
||||
public class ResourcesListMethodHandler : IMcpMethodHandler
|
||||
public class ResourcesListMethodHandler(
|
||||
ILogger<ResourcesListMethodHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
: IMcpMethodHandler
|
||||
{
|
||||
private readonly ILogger<ResourcesListMethodHandler> _logger;
|
||||
private readonly IMcpResourceRegistry _resourceRegistry;
|
||||
|
||||
public string MethodName => "resources/list";
|
||||
|
||||
public ResourcesListMethodHandler(
|
||||
ILogger<ResourcesListMethodHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
{
|
||||
_logger = logger;
|
||||
_resourceRegistry = resourceRegistry;
|
||||
}
|
||||
|
||||
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Handling resources/list request");
|
||||
logger.LogDebug("Handling resources/list request");
|
||||
|
||||
// Get all registered resource descriptors with full metadata
|
||||
var descriptors = _resourceRegistry.GetResourceDescriptors();
|
||||
var categories = _resourceRegistry.GetCategories();
|
||||
var descriptors = resourceRegistry.GetResourceDescriptors();
|
||||
var categories = resourceRegistry.GetCategories();
|
||||
|
||||
_logger.LogInformation("Returning {Count} MCP resources in {CategoryCount} categories",
|
||||
logger.LogInformation("Returning {Count} MCP resources in {CategoryCount} categories",
|
||||
descriptors.Count, categories.Count);
|
||||
|
||||
// Group by category for better organization
|
||||
|
||||
@@ -10,24 +10,16 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
/// <summary>
|
||||
/// Handler for the 'resources/read' MCP method
|
||||
/// </summary>
|
||||
public class ResourcesReadMethodHandler : IMcpMethodHandler
|
||||
public class ResourcesReadMethodHandler(
|
||||
ILogger<ResourcesReadMethodHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
: IMcpMethodHandler
|
||||
{
|
||||
private readonly ILogger<ResourcesReadMethodHandler> _logger;
|
||||
private readonly IMcpResourceRegistry _resourceRegistry;
|
||||
|
||||
public string MethodName => "resources/read";
|
||||
|
||||
public ResourcesReadMethodHandler(
|
||||
ILogger<ResourcesReadMethodHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
{
|
||||
_logger = logger;
|
||||
_resourceRegistry = resourceRegistry;
|
||||
}
|
||||
|
||||
public async Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Handling resources/read request");
|
||||
logger.LogDebug("Handling resources/read request");
|
||||
|
||||
// Parse parameters
|
||||
var paramsJson = JsonSerializer.Serialize(@params);
|
||||
@@ -38,10 +30,10 @@ public class ResourcesReadMethodHandler : IMcpMethodHandler
|
||||
throw new McpInvalidParamsException("Missing required parameter: uri");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Reading resource: {Uri}", request.Uri);
|
||||
logger.LogInformation("Reading resource: {Uri}", request.Uri);
|
||||
|
||||
// Find resource by URI
|
||||
var resource = _resourceRegistry.GetResourceByUri(request.Uri);
|
||||
var resource = resourceRegistry.GetResourceByUri(request.Uri);
|
||||
if (resource == null)
|
||||
{
|
||||
throw new McpNotFoundException($"Resource not found: {request.Uri}");
|
||||
|
||||
@@ -5,20 +5,13 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
/// <summary>
|
||||
/// Handler for the 'tools/call' MCP method
|
||||
/// </summary>
|
||||
public class ToolsCallMethodHandler : IMcpMethodHandler
|
||||
public class ToolsCallMethodHandler(ILogger<ToolsCallMethodHandler> logger) : IMcpMethodHandler
|
||||
{
|
||||
private readonly ILogger<ToolsCallMethodHandler> _logger;
|
||||
|
||||
public string MethodName => "tools/call";
|
||||
|
||||
public ToolsCallMethodHandler(ILogger<ToolsCallMethodHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Handling tools/call request");
|
||||
logger.LogDebug("Handling tools/call request");
|
||||
|
||||
// TODO: Implement in Story 5.11 (Core MCP Tools)
|
||||
// For now, return error
|
||||
|
||||
@@ -5,20 +5,13 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
/// <summary>
|
||||
/// Handler for the 'tools/list' MCP method
|
||||
/// </summary>
|
||||
public class ToolsListMethodHandler : IMcpMethodHandler
|
||||
public class ToolsListMethodHandler(ILogger<ToolsListMethodHandler> logger) : IMcpMethodHandler
|
||||
{
|
||||
private readonly ILogger<ToolsListMethodHandler> _logger;
|
||||
|
||||
public string MethodName => "tools/list";
|
||||
|
||||
public ToolsListMethodHandler(ILogger<ToolsListMethodHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Handling tools/list request");
|
||||
logger.LogDebug("Handling tools/list request");
|
||||
|
||||
// TODO: Implement in Story 5.11 (Core MCP Tools)
|
||||
// For now, return empty list
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||
/// Resource: colaflow://issues.get/{id}
|
||||
/// Gets detailed information about a specific issue (Epic, Story, or Task)
|
||||
/// </summary>
|
||||
public class IssuesGetResource : IMcpResource
|
||||
public class IssuesGetResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<IssuesGetResource> logger)
|
||||
: IMcpResource
|
||||
{
|
||||
public string Uri => "colaflow://issues.get/{id}";
|
||||
public string Name => "Issue Details";
|
||||
@@ -21,25 +25,11 @@ public class IssuesGetResource : IMcpResource
|
||||
public string Category => "Issues";
|
||||
public string Version => "1.0";
|
||||
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<IssuesGetResource> _logger;
|
||||
|
||||
public IssuesGetResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<IssuesGetResource> logger)
|
||||
{
|
||||
_projectRepository = projectRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<McpResourceContent> GetContentAsync(
|
||||
McpResourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
var tenantId = tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Extract {id} from URI parameters
|
||||
if (!request.UriParams.TryGetValue("id", out var idString))
|
||||
@@ -52,10 +42,10 @@ public class IssuesGetResource : IMcpResource
|
||||
throw new McpInvalidParamsException($"Invalid issue ID format: {idString}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching issue {IssueId} for tenant {TenantId}", issueIdGuid, tenantId);
|
||||
logger.LogDebug("Fetching issue {IssueId} for tenant {TenantId}", issueIdGuid, tenantId);
|
||||
|
||||
// Try to find as Epic
|
||||
var epic = await _projectRepository.GetEpicByIdReadOnlyAsync(EpicId.From(issueIdGuid), cancellationToken);
|
||||
var epic = await projectRepository.GetEpicByIdReadOnlyAsync(EpicId.From(issueIdGuid), cancellationToken);
|
||||
if (epic != null)
|
||||
{
|
||||
var epicDto = new
|
||||
@@ -89,7 +79,7 @@ public class IssuesGetResource : IMcpResource
|
||||
}
|
||||
|
||||
// Try to find as Story
|
||||
var story = await _projectRepository.GetStoryByIdReadOnlyAsync(StoryId.From(issueIdGuid), cancellationToken);
|
||||
var story = await projectRepository.GetStoryByIdReadOnlyAsync(StoryId.From(issueIdGuid), cancellationToken);
|
||||
if (story != null)
|
||||
{
|
||||
var storyDto = new
|
||||
@@ -124,7 +114,7 @@ public class IssuesGetResource : IMcpResource
|
||||
}
|
||||
|
||||
// Try to find as Task
|
||||
var task = await _projectRepository.GetTaskByIdReadOnlyAsync(TaskId.From(issueIdGuid), cancellationToken);
|
||||
var task = await projectRepository.GetTaskByIdReadOnlyAsync(TaskId.From(issueIdGuid), cancellationToken);
|
||||
if (task != null)
|
||||
{
|
||||
var taskDto = new
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||
/// Searches issues with filters (Epics, Stories, Tasks)
|
||||
/// Query params: status, priority, assignee, type, project, limit, offset
|
||||
/// </summary>
|
||||
public class IssuesSearchResource : IMcpResource
|
||||
public class IssuesSearchResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<IssuesSearchResource> logger)
|
||||
: IMcpResource
|
||||
{
|
||||
public string Uri => "colaflow://issues.search";
|
||||
public string Name => "Issues Search";
|
||||
@@ -21,27 +25,13 @@ public class IssuesSearchResource : IMcpResource
|
||||
public string Category => "Issues";
|
||||
public string Version => "1.0";
|
||||
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<IssuesSearchResource> _logger;
|
||||
|
||||
public IssuesSearchResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<IssuesSearchResource> logger)
|
||||
{
|
||||
_projectRepository = projectRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<McpResourceContent> GetContentAsync(
|
||||
McpResourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
var tenantId = tenantContext.GetCurrentTenantId();
|
||||
|
||||
_logger.LogDebug("Searching issues for tenant {TenantId} with filters: {@Filters}",
|
||||
logger.LogDebug("Searching issues for tenant {TenantId} with filters: {@Filters}",
|
||||
tenantId, request.QueryParams);
|
||||
|
||||
// Parse query parameters
|
||||
@@ -57,13 +47,13 @@ public class IssuesSearchResource : IMcpResource
|
||||
limit = Math.Min(limit, 100);
|
||||
|
||||
// Get all projects
|
||||
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
|
||||
var projects = await projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
|
||||
|
||||
// Filter by project if specified
|
||||
if (!string.IsNullOrEmpty(projectFilter) && Guid.TryParse(projectFilter, out var projectIdGuid))
|
||||
{
|
||||
var projectId = ProjectId.From(projectIdGuid);
|
||||
var project = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
|
||||
var project = await projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
|
||||
projects = project != null ? new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project> { project } : new();
|
||||
}
|
||||
else
|
||||
@@ -72,7 +62,7 @@ public class IssuesSearchResource : IMcpResource
|
||||
var projectsWithHierarchy = new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project>();
|
||||
foreach (var p in projects)
|
||||
{
|
||||
var fullProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
|
||||
var fullProject = await projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
|
||||
if (fullProject != null)
|
||||
{
|
||||
projectsWithHierarchy.Add(fullProject);
|
||||
@@ -180,7 +170,7 @@ public class IssuesSearchResource : IMcpResource
|
||||
offset = offset
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
_logger.LogInformation("Found {Count} issues for tenant {TenantId} (total: {Total})",
|
||||
logger.LogInformation("Found {Count} issues for tenant {TenantId} (total: {Total})",
|
||||
paginatedIssues.Count, tenantId, total);
|
||||
|
||||
return new McpResourceContent
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||
/// Resource: colaflow://projects.get/{id}
|
||||
/// Gets detailed information about a specific project
|
||||
/// </summary>
|
||||
public class ProjectsGetResource : IMcpResource
|
||||
public class ProjectsGetResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<ProjectsGetResource> logger)
|
||||
: IMcpResource
|
||||
{
|
||||
public string Uri => "colaflow://projects.get/{id}";
|
||||
public string Name => "Project Details";
|
||||
@@ -21,20 +25,6 @@ public class ProjectsGetResource : IMcpResource
|
||||
public string Category => "Projects";
|
||||
public string Version => "1.0";
|
||||
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<ProjectsGetResource> _logger;
|
||||
|
||||
public ProjectsGetResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<ProjectsGetResource> logger)
|
||||
{
|
||||
_projectRepository = projectRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public McpResourceDescriptor GetDescriptor()
|
||||
{
|
||||
return new McpResourceDescriptor
|
||||
@@ -63,7 +53,7 @@ public class ProjectsGetResource : IMcpResource
|
||||
McpResourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
var tenantId = tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Extract {id} from URI parameters
|
||||
if (!request.UriParams.TryGetValue("id", out var idString))
|
||||
@@ -78,10 +68,10 @@ public class ProjectsGetResource : IMcpResource
|
||||
|
||||
var projectId = ProjectId.From(projectIdGuid);
|
||||
|
||||
_logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId}", projectId, tenantId);
|
||||
logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId}", projectId, tenantId);
|
||||
|
||||
// Get project with full hierarchy (read-only)
|
||||
var project = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
|
||||
var project = await projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
|
||||
|
||||
if (project == null)
|
||||
{
|
||||
@@ -113,7 +103,7 @@ public class ProjectsGetResource : IMcpResource
|
||||
|
||||
var json = JsonSerializer.Serialize(projectDto, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
_logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId}", projectId, tenantId);
|
||||
logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId}", projectId, tenantId);
|
||||
|
||||
return new McpResourceContent
|
||||
{
|
||||
|
||||
@@ -10,7 +10,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||
/// Resource: colaflow://projects.list
|
||||
/// Lists all projects in the current tenant
|
||||
/// </summary>
|
||||
public class ProjectsListResource : IMcpResource
|
||||
public class ProjectsListResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<ProjectsListResource> logger)
|
||||
: IMcpResource
|
||||
{
|
||||
public string Uri => "colaflow://projects.list";
|
||||
public string Name => "Projects List";
|
||||
@@ -19,20 +23,6 @@ public class ProjectsListResource : IMcpResource
|
||||
public string Category => "Projects";
|
||||
public string Version => "1.0";
|
||||
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<ProjectsListResource> _logger;
|
||||
|
||||
public ProjectsListResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<ProjectsListResource> logger)
|
||||
{
|
||||
_projectRepository = projectRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public McpResourceDescriptor GetDescriptor()
|
||||
{
|
||||
return new McpResourceDescriptor
|
||||
@@ -58,12 +48,12 @@ public class ProjectsListResource : IMcpResource
|
||||
McpResourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
var tenantId = tenantContext.GetCurrentTenantId();
|
||||
|
||||
_logger.LogDebug("Fetching projects list for tenant {TenantId}", tenantId);
|
||||
logger.LogDebug("Fetching projects list for tenant {TenantId}", tenantId);
|
||||
|
||||
// Get all projects (read-only)
|
||||
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
|
||||
var projects = await projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
|
||||
|
||||
// Map to DTOs
|
||||
var projectDtos = projects.Select(p => new
|
||||
@@ -84,7 +74,7 @@ public class ProjectsListResource : IMcpResource
|
||||
total = projectDtos.Count
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
_logger.LogInformation("Retrieved {Count} projects for tenant {TenantId}", projectDtos.Count, tenantId);
|
||||
logger.LogInformation("Retrieved {Count} projects for tenant {TenantId}", projectDtos.Count, tenantId);
|
||||
|
||||
return new McpResourceContent
|
||||
{
|
||||
|
||||
@@ -11,7 +11,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||
/// Resource: colaflow://sprints.current
|
||||
/// Gets the currently active Sprint(s)
|
||||
/// </summary>
|
||||
public class SprintsCurrentResource : IMcpResource
|
||||
public class SprintsCurrentResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<SprintsCurrentResource> logger)
|
||||
: IMcpResource
|
||||
{
|
||||
public string Uri => "colaflow://sprints.current";
|
||||
public string Name => "Current Sprint";
|
||||
@@ -20,34 +24,20 @@ public class SprintsCurrentResource : IMcpResource
|
||||
public string Category => "Sprints";
|
||||
public string Version => "1.0";
|
||||
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<SprintsCurrentResource> _logger;
|
||||
|
||||
public SprintsCurrentResource(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<SprintsCurrentResource> logger)
|
||||
{
|
||||
_projectRepository = projectRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<McpResourceContent> GetContentAsync(
|
||||
McpResourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
var tenantId = tenantContext.GetCurrentTenantId();
|
||||
|
||||
_logger.LogDebug("Fetching active sprints for tenant {TenantId}", tenantId);
|
||||
logger.LogDebug("Fetching active sprints for tenant {TenantId}", tenantId);
|
||||
|
||||
// Get active sprints
|
||||
var activeSprints = await _projectRepository.GetActiveSprintsAsync(cancellationToken);
|
||||
var activeSprints = await projectRepository.GetActiveSprintsAsync(cancellationToken);
|
||||
|
||||
if (activeSprints.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
|
||||
logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
|
||||
throw new McpNotFoundException("No active sprints found");
|
||||
}
|
||||
|
||||
@@ -75,7 +65,7 @@ public class SprintsCurrentResource : IMcpResource
|
||||
total = sprintDtos.Count
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
_logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId}",
|
||||
logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId}",
|
||||
sprintDtos.Count, tenantId);
|
||||
|
||||
return new McpResourceContent
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||
/// Lists all team members in the current tenant
|
||||
/// Query params: project (optional filter by project)
|
||||
/// </summary>
|
||||
public class UsersListResource : IMcpResource
|
||||
public class UsersListResource(
|
||||
IUserRepository userRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<UsersListResource> logger)
|
||||
: IMcpResource
|
||||
{
|
||||
public string Uri => "colaflow://users.list";
|
||||
public string Name => "Team Members";
|
||||
@@ -21,30 +25,16 @@ public class UsersListResource : IMcpResource
|
||||
public string Category => "Users";
|
||||
public string Version => "1.0";
|
||||
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<UsersListResource> _logger;
|
||||
|
||||
public UsersListResource(
|
||||
IUserRepository userRepository,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<UsersListResource> logger)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<McpResourceContent> GetContentAsync(
|
||||
McpResourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
var tenantId = tenantContext.GetCurrentTenantId();
|
||||
|
||||
_logger.LogDebug("Fetching users list for tenant {TenantId}", tenantId);
|
||||
logger.LogDebug("Fetching users list for tenant {TenantId}", tenantId);
|
||||
|
||||
// Get all users for tenant
|
||||
var users = await _userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
|
||||
var users = await userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
|
||||
|
||||
// Map to DTOs
|
||||
var userDtos = users.Select(u => new
|
||||
@@ -64,7 +54,7 @@ public class UsersListResource : IMcpResource
|
||||
total = userDtos.Count
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
_logger.LogInformation("Retrieved {Count} users for tenant {TenantId}", userDtos.Count, tenantId);
|
||||
logger.LogInformation("Retrieved {Count} users for tenant {TenantId}", userDtos.Count, tenantId);
|
||||
|
||||
return new McpResourceContent
|
||||
{
|
||||
|
||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||
/// <summary>
|
||||
/// Service implementation for MCP API Key management
|
||||
/// </summary>
|
||||
public class McpApiKeyService : IMcpApiKeyService
|
||||
public class McpApiKeyService(
|
||||
IMcpApiKeyRepository repository,
|
||||
ILogger<McpApiKeyService> logger)
|
||||
: IMcpApiKeyService
|
||||
{
|
||||
private readonly IMcpApiKeyRepository _repository;
|
||||
private readonly ILogger<McpApiKeyService> _logger;
|
||||
|
||||
public McpApiKeyService(
|
||||
IMcpApiKeyRepository repository,
|
||||
ILogger<McpApiKeyService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IMcpApiKeyRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
private readonly ILogger<McpApiKeyService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task<CreateApiKeyResponse> CreateAsync(CreateApiKeyRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -8,32 +8,26 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||
/// Implementation of MCP Resource Registry
|
||||
/// Enhanced with category support and dynamic registration
|
||||
/// </summary>
|
||||
public class McpResourceRegistry : IMcpResourceRegistry
|
||||
public class McpResourceRegistry(ILogger<McpResourceRegistry> logger) : IMcpResourceRegistry
|
||||
{
|
||||
private readonly ILogger<McpResourceRegistry> _logger;
|
||||
private readonly Dictionary<string, IMcpResource> _resources = new();
|
||||
private readonly List<IMcpResource> _resourceList = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public McpResourceRegistry(ILogger<McpResourceRegistry> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void RegisterResource(IMcpResource resource)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_resources.ContainsKey(resource.Uri))
|
||||
{
|
||||
_logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
|
||||
logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
|
||||
_resourceList.Remove(_resources[resource.Uri]);
|
||||
}
|
||||
|
||||
_resources[resource.Uri] = resource;
|
||||
_resourceList.Add(resource);
|
||||
|
||||
_logger.LogInformation("Registered MCP Resource: {Uri} - {Name} [{Category}]",
|
||||
logger.LogInformation("Registered MCP Resource: {Uri} - {Name} [{Category}]",
|
||||
resource.Uri, resource.Name, resource.Category);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,27 +13,19 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||
/// <summary>
|
||||
/// Service implementation for PendingChange management
|
||||
/// </summary>
|
||||
public class PendingChangeService : IPendingChangeService
|
||||
public class PendingChangeService(
|
||||
IPendingChangeRepository repository,
|
||||
ITenantContext tenantContext,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IPublisher publisher,
|
||||
ILogger<PendingChangeService> logger)
|
||||
: IPendingChangeService
|
||||
{
|
||||
private readonly IPendingChangeRepository _repository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IPublisher _publisher;
|
||||
private readonly ILogger<PendingChangeService> _logger;
|
||||
|
||||
public PendingChangeService(
|
||||
IPendingChangeRepository repository,
|
||||
ITenantContext tenantContext,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IPublisher publisher,
|
||||
ILogger<PendingChangeService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IPendingChangeRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
private readonly IPublisher _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
private readonly ILogger<PendingChangeService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task<PendingChangeDto> CreateAsync(
|
||||
CreatePendingChangeRequest request,
|
||||
|
||||
@@ -9,18 +9,11 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||
/// Implementation of Resource Discovery Service
|
||||
/// Scans assemblies to find all IMcpResource implementations
|
||||
/// </summary>
|
||||
public class ResourceDiscoveryService : IResourceDiscoveryService
|
||||
public class ResourceDiscoveryService(ILogger<ResourceDiscoveryService> logger) : IResourceDiscoveryService
|
||||
{
|
||||
private readonly ILogger<ResourceDiscoveryService> _logger;
|
||||
|
||||
public ResourceDiscoveryService(ILogger<ResourceDiscoveryService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Type> DiscoverResourceTypes()
|
||||
{
|
||||
_logger.LogInformation("Starting MCP Resource discovery via Assembly scanning...");
|
||||
logger.LogInformation("Starting MCP Resource discovery via Assembly scanning...");
|
||||
|
||||
var resourceTypes = new List<Type>();
|
||||
|
||||
@@ -29,7 +22,7 @@ public class ResourceDiscoveryService : IResourceDiscoveryService
|
||||
.Where(a => !a.IsDynamic && a.FullName != null && a.FullName.StartsWith("ColaFlow"))
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("Scanning {Count} assemblies for IMcpResource implementations", assemblies.Count);
|
||||
logger.LogDebug("Scanning {Count} assemblies for IMcpResource implementations", assemblies.Count);
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
@@ -44,18 +37,18 @@ public class ResourceDiscoveryService : IResourceDiscoveryService
|
||||
|
||||
if (types.Any())
|
||||
{
|
||||
_logger.LogDebug("Found {Count} resources in assembly {Assembly}",
|
||||
logger.LogDebug("Found {Count} resources in assembly {Assembly}",
|
||||
types.Count, assembly.GetName().Name);
|
||||
resourceTypes.AddRange(types);
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load types from assembly {Assembly}", assembly.FullName);
|
||||
logger.LogWarning(ex, "Failed to load types from assembly {Assembly}", assembly.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discovered {Count} MCP Resource types", resourceTypes.Count);
|
||||
logger.LogInformation("Discovered {Count} MCP Resource types", resourceTypes.Count);
|
||||
|
||||
return resourceTypes.AsReadOnly();
|
||||
}
|
||||
@@ -75,17 +68,17 @@ public class ResourceDiscoveryService : IResourceDiscoveryService
|
||||
if (resource != null)
|
||||
{
|
||||
resources.Add(resource);
|
||||
_logger.LogDebug("Instantiated resource: {ResourceType} -> {Uri}",
|
||||
logger.LogDebug("Instantiated resource: {ResourceType} -> {Uri}",
|
||||
resourceType.Name, resource.Uri);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to instantiate resource type {ResourceType}", resourceType.FullName);
|
||||
logger.LogError(ex, "Failed to instantiate resource type {ResourceType}", resourceType.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Instantiated {Count} MCP Resources", resources.Count);
|
||||
logger.LogInformation("Instantiated {Count} MCP Resources", resources.Count);
|
||||
|
||||
return resources.AsReadOnly();
|
||||
}
|
||||
|
||||
@@ -16,13 +16,19 @@ namespace ColaFlow.Modules.Mcp.Application.Tools;
|
||||
/// Adds a comment to an existing Issue
|
||||
/// Generates a Diff Preview and creates a PendingChange for approval
|
||||
/// </summary>
|
||||
public class AddCommentTool : IMcpTool
|
||||
public class AddCommentTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IIssueRepository issueRepository,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<AddCommentTool> logger)
|
||||
: IMcpTool
|
||||
{
|
||||
private readonly IPendingChangeService _pendingChangeService;
|
||||
private readonly IIssueRepository _issueRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly DiffPreviewService _diffPreviewService;
|
||||
private readonly ILogger<AddCommentTool> _logger;
|
||||
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
private readonly IIssueRepository _issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
|
||||
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
private readonly DiffPreviewService _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
private readonly ILogger<AddCommentTool> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public string Name => "add_comment";
|
||||
|
||||
@@ -52,20 +58,6 @@ public class AddCommentTool : IMcpTool
|
||||
Required = new List<string> { "issueId", "content" }
|
||||
};
|
||||
|
||||
public AddCommentTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IIssueRepository issueRepository,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<AddCommentTool> logger)
|
||||
{
|
||||
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
_issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<McpToolResult> ExecuteAsync(
|
||||
McpToolCall toolCall,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -18,13 +18,19 @@ namespace ColaFlow.Modules.Mcp.Application.Tools;
|
||||
/// Creates a new Issue (Epic, Story, Task, or Bug)
|
||||
/// Generates a Diff Preview and creates a PendingChange for approval
|
||||
/// </summary>
|
||||
public class CreateIssueTool : IMcpTool
|
||||
public class CreateIssueTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<CreateIssueTool> logger)
|
||||
: IMcpTool
|
||||
{
|
||||
private readonly IPendingChangeService _pendingChangeService;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly DiffPreviewService _diffPreviewService;
|
||||
private readonly ILogger<CreateIssueTool> _logger;
|
||||
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
private readonly DiffPreviewService _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
private readonly ILogger<CreateIssueTool> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public string Name => "create_issue";
|
||||
|
||||
@@ -77,20 +83,6 @@ public class CreateIssueTool : IMcpTool
|
||||
Required = new List<string> { "projectId", "title", "type" }
|
||||
};
|
||||
|
||||
public CreateIssueTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<CreateIssueTool> logger)
|
||||
{
|
||||
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<McpToolResult> ExecuteAsync(
|
||||
McpToolCall toolCall,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -16,12 +16,17 @@ namespace ColaFlow.Modules.Mcp.Application.Tools;
|
||||
/// Updates the status of an existing Issue
|
||||
/// Generates a Diff Preview and creates a PendingChange for approval
|
||||
/// </summary>
|
||||
public class UpdateStatusTool : IMcpTool
|
||||
public class UpdateStatusTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IIssueRepository issueRepository,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<UpdateStatusTool> logger)
|
||||
: IMcpTool
|
||||
{
|
||||
private readonly IPendingChangeService _pendingChangeService;
|
||||
private readonly IIssueRepository _issueRepository;
|
||||
private readonly DiffPreviewService _diffPreviewService;
|
||||
private readonly ILogger<UpdateStatusTool> _logger;
|
||||
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
private readonly IIssueRepository _issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
|
||||
private readonly DiffPreviewService _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
private readonly ILogger<UpdateStatusTool> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public string Name => "update_status";
|
||||
|
||||
@@ -50,18 +55,6 @@ public class UpdateStatusTool : IMcpTool
|
||||
Required = new List<string> { "issueId", "newStatus" }
|
||||
};
|
||||
|
||||
public UpdateStatusTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IIssueRepository issueRepository,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<UpdateStatusTool> logger)
|
||||
{
|
||||
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
_issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
|
||||
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<McpToolResult> ExecuteAsync(
|
||||
McpToolCall toolCall,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -6,14 +6,9 @@ namespace ColaFlow.Modules.Mcp.Domain.Services;
|
||||
/// <summary>
|
||||
/// Domain service for managing task locks and concurrency control
|
||||
/// </summary>
|
||||
public sealed class TaskLockService
|
||||
public sealed class TaskLockService(ITaskLockRepository taskLockRepository)
|
||||
{
|
||||
private readonly ITaskLockRepository _taskLockRepository;
|
||||
|
||||
public TaskLockService(ITaskLockRepository taskLockRepository)
|
||||
{
|
||||
_taskLockRepository = taskLockRepository ?? throw new ArgumentNullException(nameof(taskLockRepository));
|
||||
}
|
||||
private readonly ITaskLockRepository _taskLockRepository = taskLockRepository ?? throw new ArgumentNullException(nameof(taskLockRepository));
|
||||
|
||||
/// <summary>
|
||||
/// Try to acquire a lock for a resource
|
||||
|
||||
@@ -71,18 +71,11 @@ public class McpAuditStatistics
|
||||
/// <summary>
|
||||
/// Implementation of MCP security audit logger
|
||||
/// </summary>
|
||||
public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
|
||||
public class McpSecurityAuditLogger(ILogger<McpSecurityAuditLogger> logger) : IMcpSecurityAuditLogger
|
||||
{
|
||||
private readonly ILogger<McpSecurityAuditLogger> _logger;
|
||||
private readonly McpAuditStatistics _statistics;
|
||||
private readonly McpAuditStatistics _statistics = new();
|
||||
private readonly object _statsLock = new();
|
||||
|
||||
public McpSecurityAuditLogger(ILogger<McpSecurityAuditLogger> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_statistics = new McpAuditStatistics();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log successful MCP operation
|
||||
/// </summary>
|
||||
@@ -94,7 +87,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
|
||||
_statistics.SuccessfulOperations++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
logger.LogInformation(
|
||||
"MCP Operation SUCCESS | Tenant: {TenantId} | User: {UserId} | Operation: {Operation} | Resource: {ResourceType}/{ResourceId}",
|
||||
auditEvent.TenantId,
|
||||
auditEvent.UserId,
|
||||
@@ -115,7 +108,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
|
||||
_statistics.AuthenticationFailures++;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"MCP Authentication FAILURE | IP: {IpAddress} | Reason: {ErrorMessage}",
|
||||
auditEvent.IpAddress,
|
||||
auditEvent.ErrorMessage);
|
||||
@@ -134,7 +127,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
|
||||
_statistics.LastCrossTenantAttempt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_logger.LogCritical(
|
||||
logger.LogCritical(
|
||||
"SECURITY ALERT: Cross-Tenant Access Attempt! | Attacker Tenant: {TenantId} | Target Tenant: {TargetTenantId} | " +
|
||||
"User: {UserId} | Resource: {ResourceType}/{ResourceId} | IP: {IpAddress}",
|
||||
auditEvent.TenantId,
|
||||
@@ -160,7 +153,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
|
||||
_statistics.AuthorizationFailures++;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"MCP Authorization FAILURE | Tenant: {TenantId} | User: {UserId} | Operation: {Operation} | " +
|
||||
"Resource: {ResourceType}/{ResourceId} | Reason: {ErrorMessage}",
|
||||
auditEvent.TenantId,
|
||||
|
||||
@@ -9,20 +9,15 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.BackgroundServices;
|
||||
/// Background service to periodically expire old PendingChanges
|
||||
/// Runs every 5 minutes and marks expired changes
|
||||
/// </summary>
|
||||
public class PendingChangeExpirationBackgroundService : BackgroundService
|
||||
public class PendingChangeExpirationBackgroundService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<PendingChangeExpirationBackgroundService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<PendingChangeExpirationBackgroundService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
private readonly ILogger<PendingChangeExpirationBackgroundService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
|
||||
|
||||
public PendingChangeExpirationBackgroundService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<PendingChangeExpirationBackgroundService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("PendingChange Expiration Background Service started");
|
||||
|
||||
@@ -9,14 +9,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Hubs;
|
||||
/// Supports notifying AI agents and users about PendingChange status updates
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class McpNotificationHub : Hub
|
||||
public class McpNotificationHub(ILogger<McpNotificationHub> logger) : Hub
|
||||
{
|
||||
private readonly ILogger<McpNotificationHub> _logger;
|
||||
|
||||
public McpNotificationHub(ILogger<McpNotificationHub> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly ILogger<McpNotificationHub> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
|
||||
@@ -9,25 +9,16 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||
/// Middleware for authenticating MCP requests using API Keys
|
||||
/// Only applies to /mcp endpoints
|
||||
/// </summary>
|
||||
public class McpApiKeyAuthenticationMiddleware
|
||||
public class McpApiKeyAuthenticationMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<McpApiKeyAuthenticationMiddleware> logger)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<McpApiKeyAuthenticationMiddleware> _logger;
|
||||
|
||||
public McpApiKeyAuthenticationMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<McpApiKeyAuthenticationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, IMcpApiKeyService apiKeyService)
|
||||
{
|
||||
// Only apply to /mcp endpoints
|
||||
if (!context.Request.Path.StartsWithSegments("/mcp"))
|
||||
{
|
||||
await _next(context);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +26,7 @@ public class McpApiKeyAuthenticationMiddleware
|
||||
var apiKey = ExtractApiKey(context.Request.Headers);
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
_logger.LogWarning("MCP request rejected - Missing API Key");
|
||||
logger.LogWarning("MCP request rejected - Missing API Key");
|
||||
await WriteUnauthorizedResponse(context, "Missing API Key. Please provide Authorization: Bearer <api_key> header.");
|
||||
return;
|
||||
}
|
||||
@@ -47,7 +38,7 @@ public class McpApiKeyAuthenticationMiddleware
|
||||
var validationResult = await apiKeyService.ValidateAsync(apiKey, ipAddress, context.RequestAborted);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
_logger.LogWarning("MCP request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
|
||||
logger.LogWarning("MCP request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
|
||||
await WriteUnauthorizedResponse(context, validationResult.ErrorMessage ?? "Invalid API Key");
|
||||
return;
|
||||
}
|
||||
@@ -59,10 +50,10 @@ public class McpApiKeyAuthenticationMiddleware
|
||||
context.Items["McpUserId"] = validationResult.UserId;
|
||||
context.Items["McpPermissions"] = validationResult.Permissions;
|
||||
|
||||
_logger.LogDebug("MCP request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
|
||||
logger.LogDebug("MCP request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
|
||||
validationResult.ApiKeyId, validationResult.TenantId, validationResult.UserId);
|
||||
|
||||
await _next(context);
|
||||
await next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -10,16 +10,10 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||
/// - Response headers (for client-side tracking)
|
||||
/// - Serilog LogContext (for structured logging)
|
||||
/// </summary>
|
||||
public class McpCorrelationIdMiddleware
|
||||
public class McpCorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private const string CorrelationIdHeaderName = "X-Correlation-Id";
|
||||
|
||||
public McpCorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Try to get correlation ID from request header, otherwise generate new one
|
||||
@@ -41,7 +35,7 @@ public class McpCorrelationIdMiddleware
|
||||
// Add to Serilog LogContext so it appears in all log entries for this request
|
||||
using (LogContext.PushProperty("CorrelationId", correlationId))
|
||||
{
|
||||
await _next(context);
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,24 +10,15 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||
/// Global exception handler middleware for MCP requests
|
||||
/// Catches all unhandled exceptions and converts them to JSON-RPC error responses
|
||||
/// </summary>
|
||||
public class McpExceptionHandlerMiddleware
|
||||
public class McpExceptionHandlerMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<McpExceptionHandlerMiddleware> logger)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<McpExceptionHandlerMiddleware> _logger;
|
||||
|
||||
public McpExceptionHandlerMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<McpExceptionHandlerMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
await next(context);
|
||||
}
|
||||
catch (McpException mcpEx)
|
||||
{
|
||||
@@ -49,7 +40,7 @@ public class McpExceptionHandlerMiddleware
|
||||
var apiKeyId = context.Items["ApiKeyId"]?.ToString();
|
||||
|
||||
// Log the error with structured data
|
||||
_logger.LogError(mcpEx,
|
||||
logger.LogError(mcpEx,
|
||||
"MCP Error: {ErrorCode} - {Message} | CorrelationId: {CorrelationId} | TenantId: {TenantId} | ApiKeyId: {ApiKeyId}",
|
||||
mcpEx.ErrorCode, mcpEx.Message, correlationId, tenantId, apiKeyId);
|
||||
|
||||
@@ -84,7 +75,7 @@ public class McpExceptionHandlerMiddleware
|
||||
var apiKeyId = context.Items["ApiKeyId"]?.ToString();
|
||||
|
||||
// Log the full exception with stack trace
|
||||
_logger.LogError(ex,
|
||||
logger.LogError(ex,
|
||||
"Unexpected error in MCP Server | CorrelationId: {CorrelationId} | TenantId: {TenantId} | ApiKeyId: {ApiKeyId}",
|
||||
correlationId, tenantId, apiKeyId);
|
||||
|
||||
|
||||
@@ -10,30 +10,21 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||
/// Middleware that logs all MCP requests and responses
|
||||
/// Includes performance timing and sensitive data sanitization
|
||||
/// </summary>
|
||||
public class McpLoggingMiddleware
|
||||
public class McpLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<McpLoggingMiddleware> logger)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<McpLoggingMiddleware> _logger;
|
||||
|
||||
// Patterns for sanitizing sensitive data
|
||||
private static readonly Regex ApiKeyHashPattern = new(@"""keyHash"":\s*""[^""]+""", RegexOptions.Compiled);
|
||||
private static readonly Regex ApiKeyPattern = new(@"""apiKey"":\s*""[^""]+""", RegexOptions.Compiled);
|
||||
private static readonly Regex PasswordPattern = new(@"""password"":\s*""[^""]+""", RegexOptions.Compiled);
|
||||
|
||||
public McpLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<McpLoggingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Only log MCP requests (POST to /mcp endpoint)
|
||||
if (!IsMcpRequest(context))
|
||||
{
|
||||
await _next(context);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,7 +45,7 @@ public class McpLoggingMiddleware
|
||||
try
|
||||
{
|
||||
// Execute the rest of the pipeline
|
||||
await _next(context);
|
||||
await next(context);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
@@ -96,7 +87,7 @@ public class McpLoggingMiddleware
|
||||
// Sanitize sensitive data before logging
|
||||
var sanitizedBody = SanitizeSensitiveData(bodyText);
|
||||
|
||||
_logger.LogDebug(
|
||||
logger.LogDebug(
|
||||
"MCP Request | Method: {Method} | Path: {Path} | CorrelationId: {CorrelationId} | " +
|
||||
"TenantId: {TenantId} | ApiKeyId: {ApiKeyId} | UserId: {UserId}\nBody: {Body}",
|
||||
context.Request.Method,
|
||||
@@ -123,7 +114,7 @@ public class McpLoggingMiddleware
|
||||
var statusCode = context.Response.StatusCode;
|
||||
var logLevel = statusCode >= 400 ? LogLevel.Error : LogLevel.Debug;
|
||||
|
||||
_logger.Log(logLevel,
|
||||
logger.Log(logLevel,
|
||||
"MCP Response | StatusCode: {StatusCode} | CorrelationId: {CorrelationId} | " +
|
||||
"Duration: {Duration}ms\nBody: {Body}",
|
||||
statusCode,
|
||||
@@ -134,7 +125,7 @@ public class McpLoggingMiddleware
|
||||
// Also log performance metrics
|
||||
if (elapsedMs > 1000) // Log slow requests (> 1 second)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
logger.LogWarning(
|
||||
"Slow MCP Request | CorrelationId: {CorrelationId} | Duration: {Duration}ms",
|
||||
correlationId,
|
||||
elapsedMs);
|
||||
|
||||
@@ -9,27 +9,18 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||
/// <summary>
|
||||
/// Middleware for handling MCP JSON-RPC 2.0 requests
|
||||
/// </summary>
|
||||
public class McpMiddleware
|
||||
public class McpMiddleware(RequestDelegate next, ILogger<McpMiddleware> logger)
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<McpMiddleware> _logger;
|
||||
|
||||
public McpMiddleware(RequestDelegate next, ILogger<McpMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, IMcpProtocolHandler protocolHandler)
|
||||
{
|
||||
// Only handle POST requests to /mcp endpoint
|
||||
if (context.Request.Method != "POST" || !context.Request.Path.StartsWithSegments("/mcp"))
|
||||
{
|
||||
await _next(context);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("MCP request received from {RemoteIp}", context.Connection.RemoteIpAddress);
|
||||
logger.LogDebug("MCP request received from {RemoteIp}", context.Connection.RemoteIpAddress);
|
||||
|
||||
JsonRpcResponse? response = null;
|
||||
JsonRpcRequest? request = null;
|
||||
@@ -40,7 +31,7 @@ public class McpMiddleware
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var requestBody = await reader.ReadToEndAsync();
|
||||
|
||||
_logger.LogTrace("MCP request body: {RequestBody}", requestBody);
|
||||
logger.LogTrace("MCP request body: {RequestBody}", requestBody);
|
||||
|
||||
// Parse JSON-RPC request
|
||||
try
|
||||
@@ -53,7 +44,7 @@ public class McpMiddleware
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse JSON-RPC request");
|
||||
logger.LogWarning(ex, "Failed to parse JSON-RPC request");
|
||||
response = JsonRpcResponse.ParseError(ex.Message);
|
||||
}
|
||||
|
||||
@@ -75,7 +66,7 @@ public class McpMiddleware
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
_logger.LogTrace("MCP response: {ResponseJson}", responseJson);
|
||||
logger.LogTrace("MCP response: {ResponseJson}", responseJson);
|
||||
|
||||
await context.Response.WriteAsync(responseJson);
|
||||
}
|
||||
@@ -83,12 +74,12 @@ public class McpMiddleware
|
||||
{
|
||||
// For notifications, return 204 No Content
|
||||
context.Response.StatusCode = 204;
|
||||
_logger.LogDebug("Notification processed, no response sent");
|
||||
logger.LogDebug("Notification processed, no response sent");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception in MCP middleware");
|
||||
logger.LogError(ex, "Unhandled exception in MCP middleware");
|
||||
|
||||
// Send internal error response (id is null because we don't know the request id)
|
||||
response = JsonRpcResponse.InternalError("Unhandled server error", null);
|
||||
|
||||
@@ -7,12 +7,8 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence;
|
||||
/// <summary>
|
||||
/// DbContext for MCP module
|
||||
/// </summary>
|
||||
public class McpDbContext : DbContext
|
||||
public class McpDbContext(DbContextOptions<McpDbContext> options) : DbContext(options)
|
||||
{
|
||||
public McpDbContext(DbContextOptions<McpDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<McpApiKey> ApiKeys => Set<McpApiKey>();
|
||||
public DbSet<PendingChange> PendingChanges => Set<PendingChange>();
|
||||
public DbSet<TaskLock> TaskLocks => Set<TaskLock>();
|
||||
|
||||
@@ -7,14 +7,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
|
||||
/// <summary>
|
||||
/// Repository implementation for MCP API Keys
|
||||
/// </summary>
|
||||
public class McpApiKeyRepository : IMcpApiKeyRepository
|
||||
public class McpApiKeyRepository(McpDbContext context) : IMcpApiKeyRepository
|
||||
{
|
||||
private readonly McpDbContext _context;
|
||||
|
||||
public McpApiKeyRepository(McpDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
private readonly McpDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
|
||||
public async Task<McpApiKey?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -8,14 +8,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
|
||||
/// <summary>
|
||||
/// Repository implementation for PendingChange aggregate
|
||||
/// </summary>
|
||||
public sealed class PendingChangeRepository : IPendingChangeRepository
|
||||
public sealed class PendingChangeRepository(McpDbContext context) : IPendingChangeRepository
|
||||
{
|
||||
private readonly McpDbContext _context;
|
||||
|
||||
public PendingChangeRepository(McpDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
private readonly McpDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
|
||||
public async Task<PendingChange?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -8,14 +8,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
|
||||
/// <summary>
|
||||
/// Repository implementation for TaskLock aggregate
|
||||
/// </summary>
|
||||
public sealed class TaskLockRepository : ITaskLockRepository
|
||||
public sealed class TaskLockRepository(McpDbContext context) : ITaskLockRepository
|
||||
{
|
||||
private readonly McpDbContext _context;
|
||||
|
||||
public TaskLockRepository(McpDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
private readonly McpDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
|
||||
public async Task<TaskLock?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -79,19 +79,11 @@ public class SecurityScore
|
||||
/// <summary>
|
||||
/// Implementation of multi-tenant security report generator
|
||||
/// </summary>
|
||||
public class MultiTenantSecurityReportGenerator : IMultiTenantSecurityReportGenerator
|
||||
public class MultiTenantSecurityReportGenerator(
|
||||
IMcpSecurityAuditLogger? auditLogger = null,
|
||||
ITenantContextValidator? tenantValidator = null)
|
||||
: IMultiTenantSecurityReportGenerator
|
||||
{
|
||||
private readonly IMcpSecurityAuditLogger? _auditLogger;
|
||||
private readonly ITenantContextValidator? _tenantValidator;
|
||||
|
||||
public MultiTenantSecurityReportGenerator(
|
||||
IMcpSecurityAuditLogger? auditLogger = null,
|
||||
ITenantContextValidator? tenantValidator = null)
|
||||
{
|
||||
_auditLogger = auditLogger;
|
||||
_tenantValidator = tenantValidator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate comprehensive security report
|
||||
/// </summary>
|
||||
@@ -100,15 +92,15 @@ public class MultiTenantSecurityReportGenerator : IMultiTenantSecurityReportGene
|
||||
var report = new MultiTenantSecurityReport();
|
||||
|
||||
// Gather audit statistics
|
||||
if (_auditLogger != null)
|
||||
if (auditLogger != null)
|
||||
{
|
||||
report.AuditStatistics = _auditLogger.GetAuditStatistics();
|
||||
report.AuditStatistics = auditLogger.GetAuditStatistics();
|
||||
}
|
||||
|
||||
// Gather validation statistics
|
||||
if (_tenantValidator != null)
|
||||
if (tenantValidator != null)
|
||||
{
|
||||
report.ValidationStatistics = _tenantValidator.GetValidationStats();
|
||||
report.ValidationStatistics = tenantValidator.GetValidationStats();
|
||||
}
|
||||
|
||||
// Perform security checks
|
||||
@@ -276,7 +268,7 @@ public class MultiTenantSecurityReportGenerator : IMultiTenantSecurityReportGene
|
||||
GlobalQueryFiltersEnabled = true, // Assumed (would need EF Core inspection)
|
||||
ApiKeyTenantBindingEnabled = true, // Verified by API Key entity
|
||||
CrossTenantAccessBlocked = true, // Verified by tests
|
||||
AuditLoggingEnabled = _auditLogger != null
|
||||
AuditLoggingEnabled = auditLogger != null
|
||||
};
|
||||
|
||||
results.TotalChecks = 5;
|
||||
|
||||
@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Services;
|
||||
/// <summary>
|
||||
/// Implementation of IMcpNotificationService using SignalR
|
||||
/// </summary>
|
||||
public class McpNotificationService : IMcpNotificationService
|
||||
public class McpNotificationService(
|
||||
IHubContext<McpNotificationHub> hubContext,
|
||||
ILogger<McpNotificationService> logger)
|
||||
: IMcpNotificationService
|
||||
{
|
||||
private readonly IHubContext<McpNotificationHub> _hubContext;
|
||||
private readonly ILogger<McpNotificationService> _logger;
|
||||
|
||||
public McpNotificationService(
|
||||
IHubContext<McpNotificationHub> hubContext,
|
||||
ILogger<McpNotificationService> logger)
|
||||
{
|
||||
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IHubContext<McpNotificationHub> _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
|
||||
private readonly ILogger<McpNotificationService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task NotifyPendingChangeCreatedAsync(
|
||||
PendingChangeCreatedNotification notification,
|
||||
|
||||
@@ -42,16 +42,9 @@ public class TenantValidationStats
|
||||
/// Implementation of tenant context validator
|
||||
/// Uses EF Core Query Tags and SQL inspection to verify tenant filtering
|
||||
/// </summary>
|
||||
public class TenantContextValidator : ITenantContextValidator
|
||||
public class TenantContextValidator(ILogger<TenantContextValidator> logger) : ITenantContextValidator
|
||||
{
|
||||
private readonly ILogger<TenantContextValidator> _logger;
|
||||
private readonly TenantValidationStats _stats;
|
||||
|
||||
public TenantContextValidator(ILogger<TenantContextValidator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_stats = new TenantValidationStats();
|
||||
}
|
||||
private readonly TenantValidationStats _stats = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a query includes TenantId filter
|
||||
@@ -61,7 +54,7 @@ public class TenantContextValidator : ITenantContextValidator
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(queryString))
|
||||
{
|
||||
_logger.LogWarning("Empty query string provided for validation");
|
||||
logger.LogWarning("Empty query string provided for validation");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -75,14 +68,14 @@ public class TenantContextValidator : ITenantContextValidator
|
||||
if (hasTenantFilter)
|
||||
{
|
||||
_stats.QueriesWithTenantFilter++;
|
||||
_logger.LogDebug("Query validation PASSED - TenantId filter present");
|
||||
logger.LogDebug("Query validation PASSED - TenantId filter present");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_stats.QueriesWithoutTenantFilter++;
|
||||
_stats.ViolatingQueries.Add(queryString);
|
||||
_logger.LogWarning("SECURITY WARNING: Query validation FAILED - No TenantId filter detected: {Query}",
|
||||
logger.LogWarning("SECURITY WARNING: Query validation FAILED - No TenantId filter detected: {Query}",
|
||||
TruncateQuery(queryString));
|
||||
return false;
|
||||
}
|
||||
@@ -96,7 +89,7 @@ public class TenantContextValidator : ITenantContextValidator
|
||||
{
|
||||
// Note: This would typically check HttpContext.Items["McpTenantId"]
|
||||
// For now, we'll log a placeholder
|
||||
_logger.LogDebug("Tenant context validation requested");
|
||||
logger.LogDebug("Tenant context validation requested");
|
||||
return true; // Placeholder - implement with actual context check
|
||||
}
|
||||
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for EpicCreatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class EpicCreatedEventHandler : INotificationHandler<EpicCreatedEvent>
|
||||
public class EpicCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<EpicCreatedEventHandler> logger)
|
||||
: INotificationHandler<EpicCreatedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<EpicCreatedEventHandler> _logger;
|
||||
|
||||
public EpicCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<EpicCreatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(EpicCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling EpicCreatedEvent for epic {EpicId}", notification.EpicId);
|
||||
logger.LogInformation("Handling EpicCreatedEvent for epic {EpicId}", notification.EpicId);
|
||||
|
||||
var epicData = new
|
||||
{
|
||||
@@ -33,12 +25,12 @@ public class EpicCreatedEventHandler : INotificationHandler<EpicCreatedEvent>
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyEpicCreated(
|
||||
await notificationService.NotifyEpicCreated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.EpicId.Value,
|
||||
epicData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
|
||||
logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,20 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for EpicDeletedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class EpicDeletedEventHandler : INotificationHandler<EpicDeletedEvent>
|
||||
public class EpicDeletedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<EpicDeletedEventHandler> logger)
|
||||
: INotificationHandler<EpicDeletedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<EpicDeletedEventHandler> _logger;
|
||||
|
||||
public EpicDeletedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<EpicDeletedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(EpicDeletedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling EpicDeletedEvent for epic {EpicId}", notification.EpicId);
|
||||
logger.LogInformation("Handling EpicDeletedEvent for epic {EpicId}", notification.EpicId);
|
||||
|
||||
await _notificationService.NotifyEpicDeleted(
|
||||
await notificationService.NotifyEpicDeleted(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.EpicId.Value);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
|
||||
logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for EpicUpdatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class EpicUpdatedEventHandler : INotificationHandler<EpicUpdatedEvent>
|
||||
public class EpicUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<EpicUpdatedEventHandler> logger)
|
||||
: INotificationHandler<EpicUpdatedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<EpicUpdatedEventHandler> _logger;
|
||||
|
||||
public EpicUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<EpicUpdatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(EpicUpdatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling EpicUpdatedEvent for epic {EpicId}", notification.EpicId);
|
||||
logger.LogInformation("Handling EpicUpdatedEvent for epic {EpicId}", notification.EpicId);
|
||||
|
||||
var epicData = new
|
||||
{
|
||||
@@ -33,12 +25,12 @@ public class EpicUpdatedEventHandler : INotificationHandler<EpicUpdatedEvent>
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyEpicUpdated(
|
||||
await notificationService.NotifyEpicUpdated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.EpicId.Value,
|
||||
epicData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
|
||||
logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,38 +9,28 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for ProjectArchivedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class ProjectArchivedEventHandler : INotificationHandler<ProjectArchivedEvent>
|
||||
public class ProjectArchivedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
IProjectRepository projectRepository,
|
||||
ILogger<ProjectArchivedEventHandler> logger)
|
||||
: 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);
|
||||
logger.LogInformation("Handling ProjectArchivedEvent for project {ProjectId}", notification.ProjectId);
|
||||
|
||||
// Get full project to obtain TenantId
|
||||
var project = await _projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
|
||||
var project = await projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
|
||||
if (project == null)
|
||||
{
|
||||
_logger.LogWarning("Project {ProjectId} not found for archive notification", notification.ProjectId);
|
||||
logger.LogWarning("Project {ProjectId} not found for archive notification", notification.ProjectId);
|
||||
return;
|
||||
}
|
||||
|
||||
await _notificationService.NotifyProjectArchived(
|
||||
await notificationService.NotifyProjectArchived(
|
||||
project.TenantId.Value,
|
||||
notification.ProjectId.Value);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for archived project {ProjectId}", notification.ProjectId);
|
||||
logger.LogInformation("SignalR notification sent for archived project {ProjectId}", notification.ProjectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for ProjectCreatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class ProjectCreatedEventHandler : INotificationHandler<ProjectCreatedEvent>
|
||||
public class ProjectCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<ProjectCreatedEventHandler> logger)
|
||||
: 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);
|
||||
logger.LogInformation("Handling ProjectCreatedEvent for project {ProjectId}", notification.ProjectId);
|
||||
|
||||
var projectData = new
|
||||
{
|
||||
@@ -33,11 +25,11 @@ public class ProjectCreatedEventHandler : INotificationHandler<ProjectCreatedEve
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyProjectCreated(
|
||||
await notificationService.NotifyProjectCreated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
projectData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for project {ProjectId}", notification.ProjectId);
|
||||
logger.LogInformation("SignalR notification sent for project {ProjectId}", notification.ProjectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,31 +9,21 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for ProjectUpdatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class ProjectUpdatedEventHandler : INotificationHandler<ProjectUpdatedEvent>
|
||||
public class ProjectUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
IProjectRepository projectRepository,
|
||||
ILogger<ProjectUpdatedEventHandler> logger)
|
||||
: 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);
|
||||
logger.LogInformation("Handling ProjectUpdatedEvent for project {ProjectId}", notification.ProjectId);
|
||||
|
||||
// Get full project to obtain TenantId
|
||||
var project = await _projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
|
||||
var project = await projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
|
||||
if (project == null)
|
||||
{
|
||||
_logger.LogWarning("Project {ProjectId} not found for update notification", notification.ProjectId);
|
||||
logger.LogWarning("Project {ProjectId} not found for update notification", notification.ProjectId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,11 +35,11 @@ public class ProjectUpdatedEventHandler : INotificationHandler<ProjectUpdatedEve
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyProjectUpdated(
|
||||
await notificationService.NotifyProjectUpdated(
|
||||
project.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
projectData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for updated project {ProjectId}", notification.ProjectId);
|
||||
logger.LogInformation("SignalR notification sent for updated project {ProjectId}", notification.ProjectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for StoryCreatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class StoryCreatedEventHandler : INotificationHandler<StoryCreatedEvent>
|
||||
public class StoryCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<StoryCreatedEventHandler> logger)
|
||||
: INotificationHandler<StoryCreatedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<StoryCreatedEventHandler> _logger;
|
||||
|
||||
public StoryCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<StoryCreatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(StoryCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling StoryCreatedEvent for story {StoryId}", notification.StoryId);
|
||||
logger.LogInformation("Handling StoryCreatedEvent for story {StoryId}", notification.StoryId);
|
||||
|
||||
var storyData = new
|
||||
{
|
||||
@@ -34,13 +26,13 @@ public class StoryCreatedEventHandler : INotificationHandler<StoryCreatedEvent>
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyStoryCreated(
|
||||
await notificationService.NotifyStoryCreated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.EpicId.Value,
|
||||
notification.StoryId.Value,
|
||||
storyData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
|
||||
logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,21 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for StoryDeletedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class StoryDeletedEventHandler : INotificationHandler<StoryDeletedEvent>
|
||||
public class StoryDeletedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<StoryDeletedEventHandler> logger)
|
||||
: INotificationHandler<StoryDeletedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<StoryDeletedEventHandler> _logger;
|
||||
|
||||
public StoryDeletedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<StoryDeletedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(StoryDeletedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling StoryDeletedEvent for story {StoryId}", notification.StoryId);
|
||||
logger.LogInformation("Handling StoryDeletedEvent for story {StoryId}", notification.StoryId);
|
||||
|
||||
await _notificationService.NotifyStoryDeleted(
|
||||
await notificationService.NotifyStoryDeleted(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.EpicId.Value,
|
||||
notification.StoryId.Value);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
|
||||
logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for StoryUpdatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class StoryUpdatedEventHandler : INotificationHandler<StoryUpdatedEvent>
|
||||
public class StoryUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<StoryUpdatedEventHandler> logger)
|
||||
: INotificationHandler<StoryUpdatedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<StoryUpdatedEventHandler> _logger;
|
||||
|
||||
public StoryUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<StoryUpdatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(StoryUpdatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling StoryUpdatedEvent for story {StoryId}", notification.StoryId);
|
||||
logger.LogInformation("Handling StoryUpdatedEvent for story {StoryId}", notification.StoryId);
|
||||
|
||||
var storyData = new
|
||||
{
|
||||
@@ -34,13 +26,13 @@ public class StoryUpdatedEventHandler : INotificationHandler<StoryUpdatedEvent>
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyStoryUpdated(
|
||||
await notificationService.NotifyStoryUpdated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.EpicId.Value,
|
||||
notification.StoryId.Value,
|
||||
storyData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
|
||||
logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,30 +8,22 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for TaskAssignedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class TaskAssignedEventHandler : INotificationHandler<TaskAssignedEvent>
|
||||
public class TaskAssignedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskAssignedEventHandler> logger)
|
||||
: INotificationHandler<TaskAssignedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<TaskAssignedEventHandler> _logger;
|
||||
|
||||
public TaskAssignedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskAssignedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(TaskAssignedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling TaskAssignedEvent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("Handling TaskAssignedEvent for task {TaskId}", notification.TaskId);
|
||||
|
||||
await _notificationService.NotifyTaskAssigned(
|
||||
await notificationService.NotifyTaskAssigned(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.TaskId.Value,
|
||||
notification.AssigneeId.Value);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for task {TaskId} assigned to {AssigneeId}",
|
||||
logger.LogInformation("SignalR notification sent for task {TaskId} assigned to {AssigneeId}",
|
||||
notification.TaskId, notification.AssigneeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for TaskCreatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
|
||||
public class TaskCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskCreatedEventHandler> logger)
|
||||
: INotificationHandler<TaskCreatedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<TaskCreatedEventHandler> _logger;
|
||||
|
||||
public TaskCreatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskCreatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(TaskCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling TaskCreatedEvent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("Handling TaskCreatedEvent for task {TaskId}", notification.TaskId);
|
||||
|
||||
var taskData = new
|
||||
{
|
||||
@@ -34,13 +26,13 @@ public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyTaskCreated(
|
||||
await notificationService.NotifyTaskCreated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.StoryId.Value,
|
||||
notification.TaskId.Value,
|
||||
taskData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,21 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for TaskDeletedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class TaskDeletedEventHandler : INotificationHandler<TaskDeletedEvent>
|
||||
public class TaskDeletedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskDeletedEventHandler> logger)
|
||||
: INotificationHandler<TaskDeletedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<TaskDeletedEventHandler> _logger;
|
||||
|
||||
public TaskDeletedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskDeletedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(TaskDeletedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling TaskDeletedEvent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("Handling TaskDeletedEvent for task {TaskId}", notification.TaskId);
|
||||
|
||||
await _notificationService.NotifyTaskDeleted(
|
||||
await notificationService.NotifyTaskDeleted(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.StoryId.Value,
|
||||
notification.TaskId.Value);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
|
||||
/// <summary>
|
||||
/// Handler for TaskUpdatedEvent - sends SignalR notification
|
||||
/// </summary>
|
||||
public class TaskUpdatedEventHandler : INotificationHandler<TaskUpdatedEvent>
|
||||
public class TaskUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskUpdatedEventHandler> logger)
|
||||
: INotificationHandler<TaskUpdatedEvent>
|
||||
{
|
||||
private readonly IProjectNotificationService _notificationService;
|
||||
private readonly ILogger<TaskUpdatedEventHandler> _logger;
|
||||
|
||||
public TaskUpdatedEventHandler(
|
||||
IProjectNotificationService notificationService,
|
||||
ILogger<TaskUpdatedEventHandler> logger)
|
||||
{
|
||||
_notificationService = notificationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(TaskUpdatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Handling TaskUpdatedEvent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("Handling TaskUpdatedEvent for task {TaskId}", notification.TaskId);
|
||||
|
||||
var taskData = new
|
||||
{
|
||||
@@ -34,13 +26,13 @@ public class TaskUpdatedEventHandler : INotificationHandler<TaskUpdatedEvent>
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _notificationService.NotifyTaskUpdated(
|
||||
await notificationService.NotifyTaskUpdated(
|
||||
notification.TenantId.Value,
|
||||
notification.ProjectId.Value,
|
||||
notification.StoryId.Value,
|
||||
notification.TaskId.Value,
|
||||
taskData);
|
||||
|
||||
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
|
||||
logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,12 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.AuditLogs.GetAu
|
||||
/// Handler for GetAuditLogByIdQuery
|
||||
/// Retrieves a single audit log entry by its unique identifier
|
||||
/// </summary>
|
||||
public class GetAuditLogByIdQueryHandler : IRequestHandler<GetAuditLogByIdQuery, AuditLogDto?>
|
||||
public class GetAuditLogByIdQueryHandler(IAuditLogRepository auditLogRepository)
|
||||
: IRequestHandler<GetAuditLogByIdQuery, AuditLogDto?>
|
||||
{
|
||||
private readonly IAuditLogRepository _auditLogRepository;
|
||||
|
||||
public GetAuditLogByIdQueryHandler(IAuditLogRepository auditLogRepository)
|
||||
{
|
||||
_auditLogRepository = auditLogRepository;
|
||||
}
|
||||
|
||||
public async Task<AuditLogDto?> Handle(GetAuditLogByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var auditLog = await _auditLogRepository.GetByIdAsync(request.AuditLogId, cancellationToken);
|
||||
var auditLog = await auditLogRepository.GetByIdAsync(request.AuditLogId, cancellationToken);
|
||||
|
||||
if (auditLog == null)
|
||||
return null;
|
||||
|
||||
@@ -8,18 +8,12 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.AuditLogs.GetAu
|
||||
/// Retrieves all audit log entries for a specific entity (e.g., all changes to a Project)
|
||||
/// Results are automatically filtered by tenant via global query filter
|
||||
/// </summary>
|
||||
public class GetAuditLogsByEntityQueryHandler : IRequestHandler<GetAuditLogsByEntityQuery, IReadOnlyList<AuditLogDto>>
|
||||
public class GetAuditLogsByEntityQueryHandler(IAuditLogRepository auditLogRepository)
|
||||
: IRequestHandler<GetAuditLogsByEntityQuery, IReadOnlyList<AuditLogDto>>
|
||||
{
|
||||
private readonly IAuditLogRepository _auditLogRepository;
|
||||
|
||||
public GetAuditLogsByEntityQueryHandler(IAuditLogRepository auditLogRepository)
|
||||
{
|
||||
_auditLogRepository = auditLogRepository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditLogDto>> Handle(GetAuditLogsByEntityQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var auditLogs = await _auditLogRepository.GetByEntityAsync(
|
||||
var auditLogs = await auditLogRepository.GetByEntityAsync(
|
||||
request.EntityType,
|
||||
request.EntityId,
|
||||
cancellationToken);
|
||||
|
||||
@@ -8,18 +8,12 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.AuditLogs.GetRe
|
||||
/// Retrieves the most recent audit log entries across all entities
|
||||
/// Results are automatically filtered by tenant via global query filter
|
||||
/// </summary>
|
||||
public class GetRecentAuditLogsQueryHandler : IRequestHandler<GetRecentAuditLogsQuery, IReadOnlyList<AuditLogDto>>
|
||||
public class GetRecentAuditLogsQueryHandler(IAuditLogRepository auditLogRepository)
|
||||
: IRequestHandler<GetRecentAuditLogsQuery, IReadOnlyList<AuditLogDto>>
|
||||
{
|
||||
private readonly IAuditLogRepository _auditLogRepository;
|
||||
|
||||
public GetRecentAuditLogsQueryHandler(IAuditLogRepository auditLogRepository)
|
||||
{
|
||||
_auditLogRepository = auditLogRepository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditLogDto>> Handle(GetRecentAuditLogsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var auditLogs = await _auditLogRepository.GetRecentAsync(request.Count, cancellationToken);
|
||||
var auditLogs = await auditLogRepository.GetRecentAsync(request.Count, cancellationToken);
|
||||
|
||||
return auditLogs
|
||||
.Select(a => new AuditLogDto(
|
||||
|
||||
@@ -11,21 +11,15 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintBurndo
|
||||
/// Handler for GetSprintBurndownQuery
|
||||
/// Calculates ideal and actual burndown data for sprint progress visualization
|
||||
/// </summary>
|
||||
public sealed class GetSprintBurndownQueryHandler : IRequestHandler<GetSprintBurndownQuery, BurndownChartDto?>
|
||||
public sealed class GetSprintBurndownQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IApplicationDbContext context,
|
||||
ILogger<GetSprintBurndownQueryHandler> logger)
|
||||
: IRequestHandler<GetSprintBurndownQuery, BurndownChartDto?>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<GetSprintBurndownQueryHandler> _logger;
|
||||
|
||||
public GetSprintBurndownQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IApplicationDbContext context,
|
||||
ILogger<GetSprintBurndownQueryHandler> logger)
|
||||
{
|
||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IApplicationDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
private readonly ILogger<GetSprintBurndownQueryHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public async Task<BurndownChartDto?> Handle(GetSprintBurndownQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -14,15 +14,8 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.Intercep
|
||||
/// Tracks Create/Update/Delete operations with tenant and user context
|
||||
/// Phase 2: Field-level change detection with JSON diff
|
||||
/// </summary>
|
||||
public class AuditInterceptor : SaveChangesInterceptor
|
||||
public class AuditInterceptor(ITenantContext tenantContext) : SaveChangesInterceptor
|
||||
{
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public AuditInterceptor(ITenantContext tenantContext)
|
||||
{
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
public override InterceptionResult<int> SavingChanges(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result)
|
||||
@@ -52,8 +45,8 @@ public class AuditInterceptor : SaveChangesInterceptor
|
||||
private void AuditChanges(DbContext context)
|
||||
{
|
||||
// Remove try-catch temporarily to see actual errors
|
||||
var tenantId = TenantId.From(_tenantContext.GetCurrentTenantId());
|
||||
var userId = _tenantContext.GetCurrentUserId();
|
||||
var tenantId = TenantId.From(tenantContext.GetCurrentTenantId());
|
||||
var userId = tenantContext.GetCurrentUserId();
|
||||
UserId? userIdVO = userId.HasValue ? UserId.From(userId.Value) : null;
|
||||
|
||||
var entries = context.ChangeTracker.Entries()
|
||||
|
||||
@@ -11,16 +11,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
||||
/// <summary>
|
||||
/// Project Management Module DbContext
|
||||
/// </summary>
|
||||
public class PMDbContext : DbContext, IApplicationDbContext
|
||||
public class PMDbContext(DbContextOptions<PMDbContext> options, IHttpContextAccessor httpContextAccessor)
|
||||
: DbContext(options), IApplicationDbContext
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public PMDbContext(DbContextOptions<PMDbContext> options, IHttpContextAccessor httpContextAccessor)
|
||||
: base(options)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Epic> Epics => Set<Epic>();
|
||||
public DbSet<Story> Stories => Set<Story>();
|
||||
@@ -60,7 +53,7 @@ public class PMDbContext : DbContext, IApplicationDbContext
|
||||
|
||||
private TenantId GetCurrentTenantId()
|
||||
{
|
||||
var tenantIdClaim = _httpContextAccessor?.HttpContext?.User
|
||||
var tenantIdClaim = httpContextAccessor?.HttpContext?.User
|
||||
.FindFirst("tenant_id")?.Value;
|
||||
|
||||
if (Guid.TryParse(tenantIdClaim, out var tenantId) && tenantId != Guid.Empty)
|
||||
|
||||
@@ -6,18 +6,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories;
|
||||
|
||||
public class AuditLogRepository : IAuditLogRepository
|
||||
public class AuditLogRepository(PMDbContext context) : IAuditLogRepository
|
||||
{
|
||||
private readonly PMDbContext _context;
|
||||
|
||||
public AuditLogRepository(PMDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<AuditLog?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AuditLogs
|
||||
return await context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
@@ -27,7 +20,7 @@ public class AuditLogRepository : IAuditLogRepository
|
||||
Guid entityId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AuditLogs
|
||||
return await context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(a => a.EntityType == entityType && a.EntityId == entityId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
@@ -41,7 +34,7 @@ public class AuditLogRepository : IAuditLogRepository
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userIdVO = UserId.From(userId);
|
||||
return await _context.AuditLogs
|
||||
return await context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(a => a.UserId == userIdVO)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
@@ -54,7 +47,7 @@ public class AuditLogRepository : IAuditLogRepository
|
||||
int count = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AuditLogs
|
||||
return await context.AuditLogs
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Take(count)
|
||||
@@ -63,12 +56,12 @@ public class AuditLogRepository : IAuditLogRepository
|
||||
|
||||
public async Task AddAsync(AuditLog auditLog, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.AuditLogs.AddAsync(auditLog, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await context.AuditLogs.AddAsync(auditLog, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> GetCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AuditLogs.CountAsync(cancellationToken);
|
||||
return await context.AuditLogs.CountAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Services;
|
||||
/// <summary>
|
||||
/// Implementation of project permission checking service
|
||||
/// </summary>
|
||||
public sealed class ProjectPermissionService : IProjectPermissionService
|
||||
public sealed class ProjectPermissionService(PMDbContext dbContext) : IProjectPermissionService
|
||||
{
|
||||
private readonly PMDbContext _dbContext;
|
||||
|
||||
public ProjectPermissionService(PMDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user has permission to access a project
|
||||
/// Currently checks if user is the project owner
|
||||
@@ -25,7 +18,7 @@ public sealed class ProjectPermissionService : IProjectPermissionService
|
||||
public async Task<bool> IsUserProjectMemberAsync(Guid userId, Guid projectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Query will automatically apply tenant filter from PMDbContext
|
||||
var project = await _dbContext.Projects
|
||||
var project = await dbContext.Projects
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id.Value == projectId, cancellationToken);
|
||||
|
||||
|
||||
@@ -7,18 +7,11 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Services;
|
||||
/// <summary>
|
||||
/// Implementation of ITenantContext that retrieves tenant ID from JWT claims
|
||||
/// </summary>
|
||||
public sealed class TenantContext : ITenantContext
|
||||
public sealed class TenantContext(IHttpContextAccessor httpContextAccessor) : ITenantContext
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public TenantContext(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public Guid GetCurrentTenantId()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
throw new InvalidOperationException("HTTP context is not available");
|
||||
|
||||
@@ -33,7 +26,7 @@ public sealed class TenantContext : ITenantContext
|
||||
|
||||
public Guid? GetCurrentUserId()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
return null;
|
||||
|
||||
|
||||
@@ -17,22 +17,16 @@ namespace ColaFlow.IntegrationTests.Mcp;
|
||||
/// 4. Verify ALL cross-tenant access returns 404 (NOT 403 - avoid info leakage)
|
||||
/// 5. Verify search queries NEVER return cross-tenant results
|
||||
/// </summary>
|
||||
public class McpMultiTenantIsolationTests : IClassFixture<MultiTenantTestFixture>
|
||||
public class McpMultiTenantIsolationTests(MultiTenantTestFixture fixture) : IClassFixture<MultiTenantTestFixture>
|
||||
{
|
||||
private readonly MultiTenantTestFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private readonly MultiTenantTestFixture _fixture = fixture;
|
||||
private readonly HttpClient _client = fixture.CreateClient();
|
||||
|
||||
// Test tenants
|
||||
private TenantTestData _tenantA = null!;
|
||||
private TenantTestData _tenantB = null!;
|
||||
private TenantTestData _tenantC = null!;
|
||||
|
||||
public McpMultiTenantIsolationTests(MultiTenantTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
#region Setup
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -12,14 +12,10 @@ namespace ColaFlow.IntegrationTests.Mcp;
|
||||
/// <summary>
|
||||
/// Integration tests for MCP Protocol endpoint
|
||||
/// </summary>
|
||||
public class McpProtocolIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class McpProtocolIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
: IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public McpProtocolIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task McpEndpoint_WithInitializeRequest_ReturnsSuccess()
|
||||
|
||||
Reference in New Issue
Block a user