From 33d890efbffd40b985b4fe5d17236108792865eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 21:47:59 +0000 Subject: [PATCH 01/14] feat(collaboration): scaffold Collaboration microservice for task comment timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the task comment timeline ('ветки') — user, genesis and system comments — into a dedicated Collaboration microservice, fully consistent with the existing service template (clean architecture, BuildingBlocks, gRPC+ServiceKey, Outbox, Serilog/OTEL, health checks, Docker, Ocelot). - New Planora.Collaboration.{Domain,Application,Infrastructure,Api} projects - Comment aggregate + repository + EF config (schema 'collaboration') - CQRS handlers: add/genesis/update/delete/get comments - Inbox consumers materialise system/genesis comments and cascade-delete from Todo lifecycle integration events; clean up on user deletion - Comment notifications published to Outbox -> NotificationEvent -> Realtime - Task access authorised via Todo gRPC (CheckTaskCommentAccess) so no cross-service DB reads (INV-OWN-1); avatars via Auth gRPC (cached) - Shared integration-event contracts (TaskCreated/TaskActivity/TaskDeleted) - Wiring: solution, docker-compose, Ocelot routes, Migrator, Todo gRPC port - Frontend comment API calls repointed to /collaboration/api/v1/comments Todo-side removal of comment code follows in a subsequent commit. --- .../Events/TaskActivityIntegrationEvent.cs | 38 +++ .../Events/TaskCreatedIntegrationEvent.cs | 23 ++ .../Events/TaskDeletedIntegrationEvent.cs | 20 ++ GrpcContracts/Protos/todo.proto | 16 ++ Planora.ApiGateway/ocelot.Docker.json | 24 ++ Planora.ApiGateway/ocelot.json | 24 ++ Planora.sln | 63 +++++ .../Controllers/CommentsController.cs | 112 +++++++++ .../Planora.Collaboration.Api/Dockerfile | 42 ++++ .../Planora.Collaboration.Api/GlobalUsings.cs | 10 + .../Planora.Collaboration.Api.csproj | 40 ++++ .../Planora.Collaboration.Api/Program.cs | 219 ++++++++++++++++++ .../Properties/launchSettings.json | 24 ++ .../appsettings.Docker.json | 36 +++ .../appsettings.json | 53 +++++ .../DTOs/CommentDto.cs | 22 ++ .../DependencyInjection.cs | 40 ++++ .../Commands/AddComment/AddCommentCommand.cs | 8 + .../AddComment/AddCommentCommandHandler.cs | 111 +++++++++ .../AddGenesisCommentCommand.cs | 8 + .../AddGenesisCommentCommandHandler.cs | 75 ++++++ .../DeleteComment/DeleteCommentCommand.cs | 7 + .../DeleteCommentCommandHandler.cs | 62 +++++ .../UpdateComment/UpdateCommentCommand.cs | 8 + .../UpdateCommentCommandHandler.cs | 88 +++++++ .../Queries/GetComments/GetCommentsQuery.cs | 12 + .../GetComments/GetCommentsQueryHandler.cs | 105 +++++++++ .../TaskActivityEventConsumer.cs | 58 +++++ .../TaskCreatedEventConsumer.cs | 55 +++++ .../TaskDeletedEventConsumer.cs | 39 ++++ .../UserDeletedEventConsumer.cs | 54 +++++ .../GlobalUsings.cs | 16 ++ .../Planora.Collaboration.Application.csproj | 28 +++ .../Services/ITaskAccessService.cs | 24 ++ .../Services/IUserService.cs | 18 ++ .../Entities/Comment.cs | 125 ++++++++++ .../Events/CommentAddedDomainEvent.cs | 9 + .../GlobalUsings.cs | 2 + .../Planora.Collaboration.Domain.csproj | 14 ++ .../Repositories/ICommentRepository.cs | 13 ++ .../DependencyInjection.cs | 78 +++++++ .../CollaborationDbContextFactory.cs | 26 +++ .../GlobalUsings.cs | 19 ++ .../Grpc/CachingUserService.cs | 91 ++++++++ .../Grpc/TaskAccessGrpcClient.cs | 63 +++++ .../Grpc/UserGrpcService.cs | 63 +++++ .../Persistence/CollaborationDbContext.cs | 34 +++ .../Configurations/CommentConfiguration.cs | 39 ++++ .../OutboxMessageConfiguration.cs | 40 ++++ .../Repositories/CollaborationUnitOfWork.cs | 70 ++++++ .../Repositories/CommentRepository.cs | 59 +++++ .../Repositories/OutboxRepository.cs | 46 ++++ ...lanora.Collaboration.Infrastructure.csproj | 33 +++ .../Planora.Todo.Api/appsettings.Docker.json | 9 +- docker-compose.yml | 45 +++- frontend/src/lib/api.ts | 10 +- .../Planora.Migrator/Planora.Migrator.csproj | 1 + tools/Planora.Migrator/Program.cs | 2 + 58 files changed, 2465 insertions(+), 8 deletions(-) create mode 100644 BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs create mode 100644 BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs create mode 100644 BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/Controllers/CommentsController.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/GlobalUsings.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/Planora.Collaboration.Api.csproj create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/Program.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/Properties/launchSettings.json create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/appsettings.Docker.json create mode 100644 Services/CollaborationApi/Planora.Collaboration.Api/appsettings.json create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/DTOs/CommentDto.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommand.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandHandler.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommand.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommand.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommand.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQuery.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskCreatedEventConsumer.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskDeletedEventConsumer.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/UserDeletedEventConsumer.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/GlobalUsings.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Planora.Collaboration.Application.csproj create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Services/ITaskAccessService.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Domain/Entities/Comment.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Domain/Events/CommentAddedDomainEvent.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Domain/GlobalUsings.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Domain/Planora.Collaboration.Domain.csproj create mode 100644 Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/DependencyInjection.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/DesignTime/CollaborationDbContextFactory.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/GlobalUsings.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/CollaborationDbContext.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CollaborationUnitOfWork.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/OutboxRepository.cs create mode 100644 Services/CollaborationApi/Planora.Collaboration.Infrastructure/Planora.Collaboration.Infrastructure.csproj diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs new file mode 100644 index 00000000..06aca1d9 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs @@ -0,0 +1,38 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Stable string discriminators for the kinds of task lifecycle activity that surface as + /// system comments in the Collaboration timeline. Sent on the wire as strings (not an enum + /// ordinal) so the contract stays robust across independent service deployments. + /// + public static class TaskActivityType + { + public const string Completed = "Completed"; + public const string StartedWorking = "StartedWorking"; + public const string Left = "Left"; + } + + /// + /// Raised by TodoApi on task lifecycle transitions (owner completes/starts/stops, worker + /// joins/leaves). Consumed by the Collaboration service to append a system comment to the + /// task's activity timeline. The sentence template lives in the consumer; this event only + /// carries the structured fact plus the actor's display name (freshest at publish time). + /// + public sealed class TaskActivityIntegrationEvent : IntegrationEvent + { + public Guid TaskId { get; init; } + public Guid ActorId { get; init; } + public string ActorName { get; init; } = string.Empty; + + /// One of . + public string ActivityType { get; init; } = string.Empty; + + public TaskActivityIntegrationEvent(Guid taskId, Guid actorId, string actorName, string activityType) + { + TaskId = taskId; + ActorId = actorId; + ActorName = actorName; + ActivityType = activityType; + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs new file mode 100644 index 00000000..ecf90dbc --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs @@ -0,0 +1,23 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Raised by TodoApi when a task is created. Consumed by the Collaboration service to + /// materialise the task's genesis comment (when a description was provided) and the + /// "{owner} created the task" system comment in the activity timeline ("ветка"). + /// + public sealed class TaskCreatedIntegrationEvent : IntegrationEvent + { + public Guid TaskId { get; init; } + public Guid OwnerId { get; init; } + public string OwnerName { get; init; } = string.Empty; + public string? Description { get; init; } + + public TaskCreatedIntegrationEvent(Guid taskId, Guid ownerId, string ownerName, string? description) + { + TaskId = taskId; + OwnerId = ownerId; + OwnerName = ownerName; + Description = description; + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs new file mode 100644 index 00000000..26461d70 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs @@ -0,0 +1,20 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Raised by TodoApi when a task is deleted. Replaces the former in-process cascade + /// (TodoApi used to soft-delete the comment rows in the same transaction). The + /// Collaboration service consumes this and soft-deletes every comment for the task, + /// keeping the activity timeline consistent with task lifetime via eventual consistency. + /// + public sealed class TaskDeletedIntegrationEvent : IntegrationEvent + { + public Guid TaskId { get; init; } + public Guid ActorId { get; init; } + + public TaskDeletedIntegrationEvent(Guid taskId, Guid actorId) + { + TaskId = taskId; + ActorId = actorId; + } + } +} diff --git a/GrpcContracts/Protos/todo.proto b/GrpcContracts/Protos/todo.proto index d06d4335..3ab18290 100644 --- a/GrpcContracts/Protos/todo.proto +++ b/GrpcContracts/Protos/todo.proto @@ -10,6 +10,22 @@ service TodoService { rpc CreateTodo (CreateTodoRequest) returns (CreateTodoResponse); rpc UpdateTodo (UpdateTodoRequest) returns (UpdateTodoResponse); rpc DeleteTodo (DeleteTodoRequest) returns (DeleteTodoResponse); + // Authorises a comment/timeline operation for a task. Encapsulates the task's + // ownership / sharing / public + friendship rules so the Collaboration service + // never has to read Todo's database or know about the sharing model. + rpc CheckTaskCommentAccess (CheckTaskCommentAccessRequest) returns (CheckTaskCommentAccessResponse); +} + +message CheckTaskCommentAccessRequest { + string task_id = 1; + string requester_id = 2; +} + +message CheckTaskCommentAccessResponse { + bool exists = 1; // the task exists and is not deleted + bool has_access = 2; // requester may read/write the task's comments + string owner_id = 3; // task owner — genesis author + notification target + repeated string participant_ids = 4; // owner + workers + shared-with — notification recipients } message TodoItemModel { diff --git a/Planora.ApiGateway/ocelot.Docker.json b/Planora.ApiGateway/ocelot.Docker.json index 72c9ec2c..8b393c47 100644 --- a/Planora.ApiGateway/ocelot.Docker.json +++ b/Planora.ApiGateway/ocelot.Docker.json @@ -24,6 +24,14 @@ "UpstreamHttpMethod": [ "GET" ], "RateLimitOptions": { "EnableRateLimiting": false } }, + { + "DownstreamPathTemplate": "/health", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ { "Host": "collaboration-api", "Port": 80 } ], + "UpstreamPathTemplate": "/collaboration/health", + "UpstreamHttpMethod": [ "GET" ], + "RateLimitOptions": { "EnableRateLimiting": false } + }, { "DownstreamPathTemplate": "/health", "DownstreamScheme": "http", @@ -153,6 +161,22 @@ }, "RateLimitOptions": { "EnableRateLimiting": false } }, + { + "DownstreamPathTemplate": "/api/v1/{everything}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ { "Host": "collaboration-api", "Port": 80 } ], + "UpstreamPathTemplate": "/collaboration/api/v1/{everything}", + "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE", "PATCH" ], + "AuthenticationOptions": { + "AuthenticationProviderKey": "Bearer", + "AllowedScopes": [] + }, + "AddClaims": { + "sub": "sub", + "email": "email" + }, + "RateLimitOptions": { "EnableRateLimiting": false } + }, { "DownstreamPathTemplate": "/api/v1/{everything}", "DownstreamScheme": "http", diff --git a/Planora.ApiGateway/ocelot.json b/Planora.ApiGateway/ocelot.json index 57d767c9..12e54e94 100644 --- a/Planora.ApiGateway/ocelot.json +++ b/Planora.ApiGateway/ocelot.json @@ -24,6 +24,14 @@ "UpstreamHttpMethod": [ "GET" ], "RateLimitOptions": { "EnableRateLimiting": false } }, + { + "DownstreamPathTemplate": "/health", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ { "Host": "127.0.0.1", "Port": 5060 } ], + "UpstreamPathTemplate": "/collaboration/health", + "UpstreamHttpMethod": [ "GET" ], + "RateLimitOptions": { "EnableRateLimiting": false } + }, { "DownstreamPathTemplate": "/health", "DownstreamScheme": "http", @@ -161,6 +169,22 @@ }, "RateLimitOptions": { "EnableRateLimiting": false } }, + { + "DownstreamPathTemplate": "/api/v1/{everything}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ { "Host": "127.0.0.1", "Port": 5060 } ], + "UpstreamPathTemplate": "/collaboration/api/v1/{everything}", + "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE", "PATCH" ], + "AuthenticationOptions": { + "AuthenticationProviderKey": "Bearer", + "AllowedScopes": [] + }, + "AddClaims": { + "sub": "sub", + "email": "email" + }, + "RateLimitOptions": { "EnableRateLimiting": false } + }, { "DownstreamPathTemplate": "/api/v1/{everything}", "DownstreamScheme": "http", diff --git a/Planora.sln b/Planora.sln index 71910ca3..fd96816d 100644 --- a/Planora.sln +++ b/Planora.sln @@ -85,6 +85,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.Category.Infrastruc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.Category.Api", "Services\CategoryApi\Planora.Category.Api\Planora.Category.Api.csproj", "{D8F8FF48-B87C-4257-ABA1-DC02CE059AEC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CollaborationApi", "CollaborationApi", "{B1C2D3E4-0001-4A5B-8C1D-000000000010}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.Collaboration.Application", "Services\CollaborationApi\Planora.Collaboration.Application\Planora.Collaboration.Application.csproj", "{B1C2D3E4-0002-4A5B-8C1D-000000000011}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.Collaboration.Domain", "Services\CollaborationApi\Planora.Collaboration.Domain\Planora.Collaboration.Domain.csproj", "{B1C2D3E4-0003-4A5B-8C1D-000000000012}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.Collaboration.Infrastructure", "Services\CollaborationApi\Planora.Collaboration.Infrastructure\Planora.Collaboration.Infrastructure.csproj", "{B1C2D3E4-0004-4A5B-8C1D-000000000013}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.Collaboration.Api", "Services\CollaborationApi\Planora.Collaboration.Api\Planora.Collaboration.Api.csproj", "{B1C2D3E4-0005-4A5B-8C1D-000000000014}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Planora.UnitTests", "tests\Planora.UnitTests\Planora.UnitTests.csproj", "{71A0F0F6-CF15-41A1-9AB3-552D672DA7E1}" @@ -405,6 +415,54 @@ Global {D8F8FF48-B87C-4257-ABA1-DC02CE059AEC}.Release|x64.Build.0 = Release|Any CPU {D8F8FF48-B87C-4257-ABA1-DC02CE059AEC}.Release|x86.ActiveCfg = Release|Any CPU {D8F8FF48-B87C-4257-ABA1-DC02CE059AEC}.Release|x86.Build.0 = Release|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-0002-4A5B-8C1D-000000000011}.Release|x86.Build.0 = Release|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-0003-4A5B-8C1D-000000000012}.Release|x86.Build.0 = Release|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-0004-4A5B-8C1D-000000000013}.Release|x86.Build.0 = Release|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-0005-4A5B-8C1D-000000000014}.Release|x86.Build.0 = Release|Any CPU {71A0F0F6-CF15-41A1-9AB3-552D672DA7E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {71A0F0F6-CF15-41A1-9AB3-552D672DA7E1}.Debug|Any CPU.Build.0 = Debug|Any CPU {71A0F0F6-CF15-41A1-9AB3-552D672DA7E1}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -476,6 +534,11 @@ Global {2058624C-C614-46F2-8106-84E61B0A8061} = {04487B2A-98ED-4ECC-ACD6-E85E603A8586} {743FFA0C-BFAC-4BBC-9097-47ECFCA8617E} = {04487B2A-98ED-4ECC-ACD6-E85E603A8586} {D8F8FF48-B87C-4257-ABA1-DC02CE059AEC} = {04487B2A-98ED-4ECC-ACD6-E85E603A8586} + {B1C2D3E4-0001-4A5B-8C1D-000000000010} = {36D591C7-65C7-A0D1-1CBC-10CDE441BDC8} + {B1C2D3E4-0002-4A5B-8C1D-000000000011} = {B1C2D3E4-0001-4A5B-8C1D-000000000010} + {B1C2D3E4-0003-4A5B-8C1D-000000000012} = {B1C2D3E4-0001-4A5B-8C1D-000000000010} + {B1C2D3E4-0004-4A5B-8C1D-000000000013} = {B1C2D3E4-0001-4A5B-8C1D-000000000010} + {B1C2D3E4-0005-4A5B-8C1D-000000000014} = {B1C2D3E4-0001-4A5B-8C1D-000000000010} {71A0F0F6-CF15-41A1-9AB3-552D672DA7E1} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {7B460F39-C79C-46C9-8B13-8D0E934D564F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {7F3F794F-A4D6-4956-93D9-3F3BDDC27CAF} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Controllers/CommentsController.cs b/Services/CollaborationApi/Planora.Collaboration.Api/Controllers/CommentsController.cs new file mode 100644 index 00000000..356411c0 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Controllers/CommentsController.cs @@ -0,0 +1,112 @@ +using Planora.BuildingBlocks.Application.Pagination; +using Planora.Collaboration.Application.DTOs; +using Planora.Collaboration.Application.Features.Comments.Commands.AddComment; +using Planora.Collaboration.Application.Features.Comments.Commands.AddGenesisComment; +using Planora.Collaboration.Application.Features.Comments.Commands.UpdateComment; +using Planora.Collaboration.Application.Features.Comments.Commands.DeleteComment; +using Planora.Collaboration.Application.Features.Comments.Queries.GetComments; + +namespace Planora.Collaboration.Api.Controllers +{ + /// + /// Task comment timeline ("ветки"). The route is keyed by the task id (owned by TodoApi); + /// this service authorises every operation via the Todo gRPC access contract. + /// + [ApiController] + [Route("api/v1/[controller]")] + [Authorize] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public sealed class CommentsController : ControllerBase + { + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public CommentsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + [HttpGet("{taskId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetComments( + [FromRoute] Guid taskId, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 50, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetCommentsQuery(taskId, pageNumber, pageSize), cancellationToken); + if (result.IsFailure) + return BadRequest(result.Error); + return Ok(result.Value); + } + + [HttpPost("{taskId}")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> AddComment( + [FromRoute] Guid taskId, + [FromBody] AddCommentRequest request, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new AddCommentCommand(taskId, request.Content), cancellationToken); + if (result.IsFailure) + return BadRequest(result.Error); + return StatusCode(StatusCodes.Status201Created, result.Value); + } + + [HttpPost("{taskId}/genesis")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> AddGenesisComment( + [FromRoute] Guid taskId, + [FromBody] AddGenesisCommentRequest request, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new AddGenesisCommentCommand(taskId, request.Content), cancellationToken); + if (result.IsFailure) + return BadRequest(result.Error); + return StatusCode(StatusCodes.Status201Created, result.Value); + } + + [HttpPut("{taskId}/{commentId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateComment( + [FromRoute] Guid taskId, + [FromRoute] Guid commentId, + [FromBody] UpdateCommentRequest request, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new UpdateCommentCommand(taskId, commentId, request.Content), cancellationToken); + if (result.IsFailure) + return BadRequest(result.Error); + return Ok(result.Value); + } + + [HttpDelete("{taskId}/{commentId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteComment( + [FromRoute] Guid taskId, + [FromRoute] Guid commentId, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new DeleteCommentCommand(taskId, commentId), cancellationToken); + if (result.IsFailure) + return BadRequest(result.Error); + return NoContent(); + } + } + + public sealed record AddCommentRequest(string Content); + public sealed record AddGenesisCommentRequest(string Content); + public sealed record UpdateCommentRequest(string Content); +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile b/Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile new file mode 100644 index 00000000..d357c7a6 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile @@ -0,0 +1,42 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 80 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +COPY ["Directory.Packages.props", "."] +COPY ["BuildingBlocks/Planora.BuildingBlocks.Application/Planora.BuildingBlocks.Application.csproj", "BuildingBlocks/Planora.BuildingBlocks.Application/"] +COPY ["BuildingBlocks/Planora.BuildingBlocks.Domain/Planora.BuildingBlocks.Domain.csproj", "BuildingBlocks/Planora.BuildingBlocks.Domain/"] +COPY ["BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Planora.BuildingBlocks.Infrastructure.csproj", "BuildingBlocks/Planora.BuildingBlocks.Infrastructure/"] +COPY ["GrpcContracts/GrpcContracts.csproj", "GrpcContracts/"] +COPY ["Services/CollaborationApi/Planora.Collaboration.Api/Planora.Collaboration.Api.csproj", "Services/CollaborationApi/Planora.Collaboration.Api/"] +COPY ["Services/CollaborationApi/Planora.Collaboration.Application/Planora.Collaboration.Application.csproj", "Services/CollaborationApi/Planora.Collaboration.Application/"] +COPY ["Services/CollaborationApi/Planora.Collaboration.Domain/Planora.Collaboration.Domain.csproj", "Services/CollaborationApi/Planora.Collaboration.Domain/"] +COPY ["Services/CollaborationApi/Planora.Collaboration.Infrastructure/Planora.Collaboration.Infrastructure.csproj", "Services/CollaborationApi/Planora.Collaboration.Infrastructure/"] + +RUN dotnet restore "Services/CollaborationApi/Planora.Collaboration.Api/Planora.Collaboration.Api.csproj" + +COPY BuildingBlocks/ BuildingBlocks/ +COPY GrpcContracts/ GrpcContracts/ +COPY Services/CollaborationApi/ Services/CollaborationApi/ + +WORKDIR "/src/Services/CollaborationApi/Planora.Collaboration.Api" +RUN dotnet build "Planora.Collaboration.Api.csproj" -c Release -o /app/build --no-restore + +FROM build AS publish +RUN dotnet publish "Planora.Collaboration.Api.csproj" -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:80/health || exit 1 + +ENTRYPOINT ["dotnet", "Planora.Collaboration.Api.dll"] diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/GlobalUsings.cs b/Services/CollaborationApi/Planora.Collaboration.Api/GlobalUsings.cs new file mode 100644 index 00000000..82dfd699 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/GlobalUsings.cs @@ -0,0 +1,10 @@ +// FluentValidation +global using FluentValidation; +// MediatR +global using MediatR; +// ASP.NET Core +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Mvc; +// Serilog +global using Serilog; diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Planora.Collaboration.Api.csproj b/Services/CollaborationApi/Planora.Collaboration.Api/Planora.Collaboration.Api.csproj new file mode 100644 index 00000000..ab151a58 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Planora.Collaboration.Api.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + enable + enable + latest + Linux + collaboration-api-secrets + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs new file mode 100644 index 00000000..ed457003 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs @@ -0,0 +1,219 @@ +using Planora.BuildingBlocks.Infrastructure; +using Planora.BuildingBlocks.Infrastructure.Extensions; +using Planora.BuildingBlocks.Infrastructure.Filters; +using Planora.BuildingBlocks.Infrastructure.Logging; +using Planora.BuildingBlocks.Infrastructure.Middleware; +using Planora.BuildingBlocks.Infrastructure.Persistence; +using Planora.BuildingBlocks.Infrastructure.Resilience; +using Planora.Collaboration.Application; +using Planora.Collaboration.Infrastructure; +using Planora.Collaboration.Infrastructure.Persistence; +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.Collaboration.Application.Features.IntegrationEvents; +using StackExchange.Redis; +using Serilog; + +namespace Planora.Collaboration.Api +{ + public class Program + { + public static async Task Main(string[] args) + { + // ✅ Enable HTTP/2 without TLS for the outbound gRPC clients (Todo / Auth) in Docker + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + + var builder = WebApplication.CreateBuilder(args); + + // ✨ Enterprise-Grade Unified Serilog Configuration + builder.ConfigureEnterpriseLogging("collaboration-api"); + + // OpenTelemetry — traces + metrics. No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset. + builder.Services.AddPlanoraTelemetry(builder.Configuration, defaultServiceName: "CollaborationService"); + + // Collaboration Application (MediatR behaviors, validators, AutoMapper profiles) + builder.Services.AddCollaborationApplication(); + + // BuildingBlocks Infrastructure (Redis, RabbitMQ, Caching) + builder.Services.AddBuildingBlocksInfrastructure(builder.Configuration, "CollaborationDatabase"); + + // Collaboration Infrastructure (DbContext, Repositories, gRPC clients, Outbox) + builder.Services.AddCollaborationInfrastructure(builder.Configuration); + + // ✅ JWT Authentication + security-stamp revocation (INV-AUTH-4) + builder.Services.AddJwtAuthenticationForConsumer(builder.Configuration); + + // API Filters + builder.Services.AddApiFilters(); + + // Response Compression + builder.Services.AddConfiguredResponseCompression(); + + // Rate Limiting + builder.Services.AddConfiguredRateLimiting(builder.Configuration); + + // CORS + builder.Services.AddCors(options => + { + var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + var devOrigins = origins.Length > 0 + ? origins + : ["http://localhost:3000", "http://127.0.0.1:3000"]; + + options.AddPolicy("AllowAll", policy => + policy.WithOrigins(devOrigins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); + + options.AddPolicy("Production", policy => + policy.WithOrigins(origins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); + }); + + // Controllers + builder.Services.AddControllers(options => + { + options.Filters.Add(); + }); + + // OpenAPI / Swagger + builder.Services.AddPlanoraSwaggerGen( + title: "Planora Collaboration API", + description: "Task comment timeline ('ветки'): user, genesis and system comments, plus comment notifications."); + + var app = builder.Build(); + + try + { + // GRACEFUL STARTUP WITH RETRY + if (!builder.Environment.IsEnvironment("Testing")) + using (var scope = app.Services.CreateScope()) + { + var provider = scope.ServiceProvider; + var logger = provider.GetRequiredService>(); + + // Wait for Database + logger.LogInformation("🔄 Waiting for database..."); + var connectionString = builder.Configuration.GetConnectionString("CollaborationDatabase")!; + await DependencyWaiter.WaitForPostgresWithDatabaseCreationAsync( + connectionString, + "planora_collaboration", + logger, + app.Lifetime.ApplicationStopping); + + // Wait for Redis + logger.LogInformation("🔄 Waiting for Redis..."); + await DependencyWaiter.WaitForRedisAsync( + async () => await ConnectionMultiplexer.ConnectAsync( + builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379"), + logger, + app.Lifetime.ApplicationStopping); + + // Apply pending migrations with retry + logger.LogInformation("🔄 Checking and applying pending migrations..."); + var db = provider.GetRequiredService(); + var migrationRetries = 0; + const int maxMigrationRetries = 5; + + while (migrationRetries < maxMigrationRetries) + { + try + { + await DatabaseStartup.EnsureReadyAsync( + db, + logger, + app.Lifetime.ApplicationStopping); + break; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception mex) + { + migrationRetries++; + logger.LogError(mex, "❌ Migration attempt {Attempt}/{Max} failed", migrationRetries, maxMigrationRetries); + if (migrationRetries >= maxMigrationRetries) + { + logger.LogCritical("💥 All migration attempts exhausted — service cannot start in broken state"); + throw; + } + await Task.Delay(TimeSpan.FromSeconds(5 * migrationRetries), app.Lifetime.ApplicationStopping); + } + } + + // Subscribe to Integration Events (Inbox side) + logger.LogInformation("🔄 Subscribing to integration events..."); + var eventBus = provider.GetRequiredService(); + + await eventBus.SubscribeAsync(app.Lifetime.ApplicationStopping); + logger.LogInformation("✅ Subscribed to TaskCreatedIntegrationEvent"); + + await eventBus.SubscribeAsync(app.Lifetime.ApplicationStopping); + logger.LogInformation("✅ Subscribed to TaskActivityIntegrationEvent"); + + await eventBus.SubscribeAsync(app.Lifetime.ApplicationStopping); + logger.LogInformation("✅ Subscribed to TaskDeletedIntegrationEvent"); + + await eventBus.SubscribeAsync(app.Lifetime.ApplicationStopping); + logger.LogInformation("✅ Subscribed to UserDeletedIntegrationEvent"); + } + + // MIDDLEWARE PIPELINE + if (!app.Environment.IsProduction()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/error"); + app.UseHsts(); + } + + app.ConfigureWebAppLogging(); + + // ✨ HTTP Request/Response Logging with Sanitization + Planora.BuildingBlocks.Infrastructure.Logging.HttpLoggingMiddlewareExtensions.UseHttpLogging(app); + + app.UseResponseCompression(); + app.UseCors(app.Environment.IsDevelopment() ? "AllowAll" : "Production"); + + // Apply rate limiting before other middleware + app.UseRateLimiter(); + + app.UseCorrelationId(); + app.UseEnhancedGlobalExceptionHandling(); + + app.UseSecurityHeaders(); + + app.UseAuthentication(); + app.UseAuthorization(); + + // Swagger UI in Development / Staging only + app.UsePlanoraSwagger(app.Environment, documentTitle: "Planora Collaboration API"); + + // Routes + app.MapControllers(); + + // Health Checks — /health/live, /health/ready, /health (aggregate) + app.MapPlanoraHealthEndpoints(); + + var appLogger = app.Services.GetRequiredService>(); + appLogger.LogInformation("🚀 CollaborationApi started successfully"); + await app.RunAsync(); + } + catch (Exception ex) + { + Log.Fatal(ex, "❌ CollaborationApi terminated unexpectedly"); + Environment.Exit(1); + } + finally + { + await Log.CloseAndFlushAsync(); + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Properties/launchSettings.json b/Services/CollaborationApi/Planora.Collaboration.Api/Properties/launchSettings.json new file mode 100644 index 00000000..43238136 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Development": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:5060", + "hotReloadProfile": "aspnetcore", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ConnectionStrings__CollaborationDatabase": "Host=localhost;Port=5433;Database=planora_collaboration;Username=postgres;Password=postgres", + "REDIS_CONNECTION": "localhost:6379,abortConnect=false", + "RABBITMQ_HOST": "localhost", + "RABBITMQ_PORT": "5672", + "RABBITMQ_USER": "guest", + "RABBITMQ_PASSWORD": "guest", + "JWT_SECRET": "DevSecretKeyThatIsAtLeast32CharactersLongForHS256Algorithm!!", + "JWT_ISSUER": "Planora.Auth", + "JWT_AUDIENCE": "Planora.Clients" + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/appsettings.Docker.json b/Services/CollaborationApi/Planora.Collaboration.Api/appsettings.Docker.json new file mode 100644 index 00000000..4e5788a5 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/appsettings.Docker.json @@ -0,0 +1,36 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "CollaborationDatabase": "Host=postgres;Port=5432;Database=planora_collaboration;Username=postgres;Password=postgres;", + "Redis": "redis:6379" + }, + "JwtSettings": { + "Secret": "", + "Issuer": "Planora.Auth", + "Audience": "Planora.Clients" + }, + "RabbitMq": { + "HostName": "rabbitmq", + "UserName": "", + "Password": "", + "Port": "5672" + }, + "GrpcServices": { + "AuthApi": "http://auth-api:80", + "TodoApi": "http://todo-api:81" + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://*:80" + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/appsettings.json b/Services/CollaborationApi/Planora.Collaboration.Api/appsettings.json new file mode 100644 index 00000000..72a4dd21 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Api/appsettings.json @@ -0,0 +1,53 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "CollaborationDatabase": "Host=localhost;Port=5433;Database=planora_collaboration;Username=postgres;Password=postgres;Maximum Pool Size=100;Minimum Pool Size=5;" + }, + "Redis": { + "Configuration": "localhost:6379,abortConnect=false" + }, + "RabbitMq": { + "HostName": "localhost", + "UserName": "", + "Password": "", + "Port": "5672" + }, + "Cache": { + "DefaultExpiration": "00:10:00", + "ShortExpiration": "00:01:00", + "LongExpiration": "01:00:00", + "EnableCompression": true, + "UseLocalCache": true, + "LocalCacheSize": 1000 + }, + "Serilog": { "Using": [ "Serilog.Sinks.Console" ], "MinimumLevel": "Information", "WriteTo": [ { "Name": "Console" } ], "Enrich": [ "FromLogContext" ] }, + "Kestrel": { + "Endpoints": { + "Rest": { + "Url": "http://*:5060", + "Protocols": "Http1" + } + } + }, + "JwtSettings": { + "Secret": "", + "Issuer": "Planora.Auth", + "Audience": "Planora.Clients" + }, + "Cors": { "AllowedOrigins": [ "http://localhost:3000", "http://localhost:3001" ] }, + "HealthChecks": { "Enabled": true }, + "GrpcServices": { + "AuthApi": "http://localhost:5031", + "TodoApi": "http://localhost:5101" + }, + "GrpcSettings": { + "ServiceKey": "" + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/DTOs/CommentDto.cs b/Services/CollaborationApi/Planora.Collaboration.Application/DTOs/CommentDto.cs new file mode 100644 index 00000000..2db7bd1f --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/DTOs/CommentDto.cs @@ -0,0 +1,22 @@ +namespace Planora.Collaboration.Application.DTOs +{ + /// + /// Wire-compatible with the former TodoApi TodoCommentDto so the frontend timeline/"ветка" + /// components need no shape changes — only their base URL moves to the Collaboration service. + /// The TodoItemId field name is kept deliberately for that JSON contract compatibility. + /// + public sealed record CommentDto( + Guid Id, + Guid TodoItemId, + Guid AuthorId, + string AuthorName, + string? AuthorAvatarUrl, + string Content, + DateTime CreatedAt, + DateTime? UpdatedAt, + bool IsOwn, + bool IsEdited, + bool IsSystemComment = false, + bool IsGenesisComment = false + ); +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs b/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs new file mode 100644 index 00000000..f9d8d3f3 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using Planora.BuildingBlocks.Application.Behaviors; +using Planora.Collaboration.Application.Features.IntegrationEvents; +using Microsoft.Extensions.DependencyInjection; +using FluentValidation; + +namespace Planora.Collaboration.Application +{ + public static class DependencyInjection + { + public static IServiceCollection AddCollaborationApplication(this IServiceCollection services) + { + var assembly = Assembly.GetExecutingAssembly(); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(assembly); + // Pipeline order (outermost → innermost): + // 1. LoggingBehavior — logs full request lifecycle including exceptions, rethrows + // 2. ValidationBehavior — throws ValidationException (caught and logged by #1) + // 3. PerformanceBehavior — measures only handler execution time + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(PerformanceBehavior<,>)); + }); + + services.AddValidatorsFromAssembly(assembly); + services.AddAutoMapper(cfg => cfg.AddMaps(assembly)); + + // Integration Event Handlers (Inbox side — materialise system/genesis comments, + // cascade-delete on task deletion, clean up on user deletion). + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommand.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommand.cs new file mode 100644 index 00000000..650598e5 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommand.cs @@ -0,0 +1,8 @@ +using Planora.BuildingBlocks.Application.CQRS; +using Planora.BuildingBlocks.Domain; +using Planora.Collaboration.Application.DTOs; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.AddComment +{ + public sealed record AddCommentCommand(Guid TaskId, string Content) : ICommand>; +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandHandler.cs new file mode 100644 index 00000000..52857d60 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandHandler.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using MediatR; +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Collaboration.Application.DTOs; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.AddComment +{ + public sealed class AddCommentCommandHandler : IRequestHandler> + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentUserContext _currentUserContext; + private readonly ITaskAccessService _taskAccessService; + private readonly IOutboxRepository _outboxRepository; + + public AddCommentCommandHandler( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ICurrentUserContext currentUserContext, + ITaskAccessService taskAccessService, + IOutboxRepository outboxRepository) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _currentUserContext = currentUserContext; + _taskAccessService = taskAccessService; + _outboxRepository = outboxRepository; + } + + public async Task> Handle(AddCommentCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + if (userId == Guid.Empty) + throw new UnauthorizedAccessException("User context is not available"); + + var access = await _taskAccessService.CheckCommentAccessAsync(request.TaskId, userId, cancellationToken); + if (!access.Exists) + throw new EntityNotFoundException("Task", request.TaskId); + if (!access.HasAccess) + throw new ForbiddenException("You do not have access to this task"); + + var authorName = _currentUserContext.Name + ?? _currentUserContext.Email + ?? userId.ToString(); + + var comment = Comment.Create(request.TaskId, userId, authorName, request.Content); + await _commentRepository.AddAsync(comment, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + // Fan out a notification to every other task participant. Written to the outbox so + // delivery is reliable and atomic-ish with the comment write (INV-COMM-3). RealtimeApi + // consumes NotificationEvent and pushes it over SignalR. + await PublishNotificationsAsync(request.TaskId, userId, authorName, access.ParticipantIds, cancellationToken); + + // Avatar URL comes from the live caller context — this is the author themselves, + // so their JWT claim is the freshest source available on the write path. + var authorAvatarUrl = string.IsNullOrEmpty(_currentUserContext.ProfilePictureUrl) + ? null + : _currentUserContext.ProfilePictureUrl; + + return Result.Success(new CommentDto( + comment.Id, + comment.TaskId, + comment.AuthorId, + comment.AuthorName, + authorAvatarUrl, + comment.Content, + comment.CreatedAt, + comment.UpdatedAt, + IsOwn: true, + IsEdited: false, + IsSystemComment: false)); + } + + private async Task PublishNotificationsAsync( + Guid taskId, + Guid authorId, + string authorName, + IReadOnlyList participantIds, + CancellationToken cancellationToken) + { + var recipients = participantIds + .Where(id => id != Guid.Empty && id != authorId) + .Distinct() + .ToList(); + + foreach (var recipientId in recipients) + { + var notification = new NotificationEvent( + recipientId, + "New comment", + $"{authorName} commented on a task", + "CommentAdded"); + + var outboxMessage = new OutboxMessage( + notification.GetType().AssemblyQualifiedName ?? notification.GetType().Name, + JsonSerializer.Serialize(notification), + DateTime.UtcNow); + + await _outboxRepository.AddAsync(outboxMessage, cancellationToken); + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommand.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommand.cs new file mode 100644 index 00000000..1e549ca9 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommand.cs @@ -0,0 +1,8 @@ +using Planora.BuildingBlocks.Application.CQRS; +using Planora.BuildingBlocks.Domain; +using Planora.Collaboration.Application.DTOs; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.AddGenesisComment +{ + public sealed record AddGenesisCommentCommand(Guid TaskId, string Content) : ICommand>; +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs new file mode 100644 index 00000000..3483ab99 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Collaboration.Application.DTOs; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.AddGenesisComment +{ + public sealed class AddGenesisCommentCommandHandler : IRequestHandler> + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentUserContext _currentUserContext; + private readonly ITaskAccessService _taskAccessService; + + public AddGenesisCommentCommandHandler( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ICurrentUserContext currentUserContext, + ITaskAccessService taskAccessService) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _currentUserContext = currentUserContext; + _taskAccessService = taskAccessService; + } + + public async Task> Handle(AddGenesisCommentCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + if (userId == Guid.Empty) + throw new UnauthorizedAccessException("User context is not available"); + + var access = await _taskAccessService.CheckCommentAccessAsync(request.TaskId, userId, cancellationToken); + if (!access.Exists) + throw new EntityNotFoundException("Task", request.TaskId); + + if (access.OwnerId != userId) + throw new ForbiddenException("Only the task owner can add a description"); + + var existing = await _commentRepository.GetGenesisCommentAsync(request.TaskId, cancellationToken); + if (existing is not null) + return Result.Failure(new Error("GENESIS_ALREADY_EXISTS", "A description already exists for this task")); + + var authorName = _currentUserContext.Name + ?? _currentUserContext.Email + ?? userId.ToString(); + + var comment = Comment.CreateGenesis(request.TaskId, request.Content, authorName); + await _commentRepository.AddAsync(comment, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + var authorAvatarUrl = string.IsNullOrEmpty(_currentUserContext.ProfilePictureUrl) + ? null + : _currentUserContext.ProfilePictureUrl; + + return Result.Success(new CommentDto( + comment.Id, + comment.TaskId, + comment.AuthorId, + comment.AuthorName, + authorAvatarUrl, + comment.Content, + comment.CreatedAt, + comment.UpdatedAt, + IsOwn: true, + IsEdited: false, + IsSystemComment: true, + IsGenesisComment: true)); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommand.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommand.cs new file mode 100644 index 00000000..4d3a12ac --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommand.cs @@ -0,0 +1,7 @@ +using Planora.BuildingBlocks.Application.CQRS; +using Planora.BuildingBlocks.Domain; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.DeleteComment +{ + public sealed record DeleteCommentCommand(Guid TaskId, Guid CommentId) : ICommand; +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs new file mode 100644 index 00000000..b82a25ae --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs @@ -0,0 +1,62 @@ +using MediatR; +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.DeleteComment +{ + public sealed class DeleteCommentCommandHandler : IRequestHandler + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentUserContext _currentUserContext; + private readonly ITaskAccessService _taskAccessService; + + public DeleteCommentCommandHandler( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ICurrentUserContext currentUserContext, + ITaskAccessService taskAccessService) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _currentUserContext = currentUserContext; + _taskAccessService = taskAccessService; + } + + public async Task Handle(DeleteCommentCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + + var comment = await _commentRepository.GetByIdAsync(request.CommentId, cancellationToken) + ?? throw new EntityNotFoundException("Comment", request.CommentId); + + if (comment.TaskId != request.TaskId) + throw new EntityNotFoundException("Comment", request.CommentId); + + if (comment.IsSystemComment && !comment.IsGenesisComment) + throw new ForbiddenException("System event comments cannot be deleted"); + + var access = await _taskAccessService.CheckCommentAccessAsync(request.TaskId, userId, cancellationToken); + if (!access.Exists) + throw new EntityNotFoundException("Task", request.TaskId); + + var isAuthor = comment.AuthorId == userId; + var isTaskOwner = access.OwnerId == userId; + + if (comment.IsGenesisComment && !isTaskOwner) + throw new ForbiddenException("Only the task owner can delete the description"); + + if (!isAuthor && !isTaskOwner) + throw new ForbiddenException("Only the comment author or task owner can delete this comment"); + + comment.MarkAsDeleted(userId); + _commentRepository.Update(comment); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommand.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommand.cs new file mode 100644 index 00000000..64bb85b1 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommand.cs @@ -0,0 +1,8 @@ +using Planora.BuildingBlocks.Application.CQRS; +using Planora.BuildingBlocks.Domain; +using Planora.Collaboration.Application.DTOs; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.UpdateComment +{ + public sealed record UpdateCommentCommand(Guid TaskId, Guid CommentId, string Content) : ICommand>; +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs new file mode 100644 index 00000000..2d0184cb --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs @@ -0,0 +1,88 @@ +using MediatR; +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Collaboration.Application.DTOs; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.Comments.Commands.UpdateComment +{ + public sealed class UpdateCommentCommandHandler : IRequestHandler> + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentUserContext _currentUserContext; + private readonly ITaskAccessService _taskAccessService; + private readonly IUserService _userService; + + public UpdateCommentCommandHandler( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ICurrentUserContext currentUserContext, + ITaskAccessService taskAccessService, + IUserService userService) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _currentUserContext = currentUserContext; + _taskAccessService = taskAccessService; + _userService = userService; + } + + public async Task> Handle(UpdateCommentCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + + var comment = await _commentRepository.GetByIdAsync(request.CommentId, cancellationToken) + ?? throw new EntityNotFoundException("Comment", request.CommentId); + + if (comment.TaskId != request.TaskId) + throw new EntityNotFoundException("Comment", request.CommentId); + + // Genesis comments are owned by the task owner (AuthorId is Empty); for them we + // resolve the avatar of the task's owner, not the current editor. + Guid resolvedAuthorId = comment.AuthorId; + + if (comment.IsGenesisComment) + { + var access = await _taskAccessService.CheckCommentAccessAsync(request.TaskId, userId, cancellationToken); + if (!access.Exists) + throw new EntityNotFoundException("Task", request.TaskId); + if (access.OwnerId != userId) + throw new ForbiddenException("Only the task owner can edit the description"); + + comment.UpdateGenesisContent(request.Content, userId); + resolvedAuthorId = access.OwnerId; + } + else + { + comment.UpdateContent(request.Content, userId); + } + + _commentRepository.Update(comment); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + string? authorAvatarUrl = null; + if (resolvedAuthorId != Guid.Empty) + { + var avatars = await _userService.GetUserAvatarsAsync(new[] { resolvedAuthorId }, cancellationToken); + avatars.TryGetValue(resolvedAuthorId, out authorAvatarUrl); + } + + return Result.Success(new CommentDto( + comment.Id, + comment.TaskId, + comment.AuthorId, + comment.AuthorName, + authorAvatarUrl, + comment.Content, + comment.CreatedAt, + comment.UpdatedAt, + IsOwn: !comment.IsGenesisComment, + IsEdited: comment.IsEdited, + IsSystemComment: comment.IsSystemComment, + IsGenesisComment: comment.IsGenesisComment)); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQuery.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQuery.cs new file mode 100644 index 00000000..b2a03169 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQuery.cs @@ -0,0 +1,12 @@ +using Planora.BuildingBlocks.Application.CQRS; +using Planora.BuildingBlocks.Application.Pagination; +using Planora.BuildingBlocks.Domain; +using Planora.Collaboration.Application.DTOs; + +namespace Planora.Collaboration.Application.Features.Comments.Queries.GetComments +{ + public sealed record GetCommentsQuery( + Guid TaskId, + int PageNumber = 1, + int PageSize = 50) : IQuery>>; +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs new file mode 100644 index 00000000..03f09bcb --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs @@ -0,0 +1,105 @@ +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.CQRS; +using Planora.BuildingBlocks.Application.Pagination; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Collaboration.Application.DTOs; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.Comments.Queries.GetComments +{ + public sealed class GetCommentsQueryHandler + : IQueryHandler>> + { + private readonly ICommentRepository _commentRepository; + private readonly ICurrentUserContext _currentUserContext; + private readonly ITaskAccessService _taskAccessService; + private readonly IUserService _userService; + + public GetCommentsQueryHandler( + ICommentRepository commentRepository, + ICurrentUserContext currentUserContext, + ITaskAccessService taskAccessService, + IUserService userService) + { + _commentRepository = commentRepository; + _currentUserContext = currentUserContext; + _taskAccessService = taskAccessService; + _userService = userService; + } + + public async Task>> Handle( + GetCommentsQuery request, CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + if (userId == Guid.Empty) + return Result>.Failure( + new Error("AUTH_REQUIRED", "User context is not available")); + + var access = await _taskAccessService.CheckCommentAccessAsync(request.TaskId, userId, cancellationToken); + if (!access.Exists) + throw new EntityNotFoundException("Task", request.TaskId); + if (!access.HasAccess) + throw new ForbiddenException("You do not have access to this task"); + + var (items, totalCount) = await _commentRepository.GetPagedByTaskIdAsync( + request.TaskId, request.PageNumber, request.PageSize, cancellationToken); + + // Always batch-fetch avatars via Auth gRPC (single source of truth is the live + // user profile), cached by CachingUserService (60 s TTL) so a paged read does not + // multiply Auth load. + // + // Regular comments: AuthorId is the actual commenter. + // Genesis comment: AuthorId = Guid.Empty by design; the real author is the + // task owner (access.OwnerId). + var authorIds = new HashSet(); + foreach (var c in items) + { + if (c.IsGenesisComment) + { + if (access.OwnerId != Guid.Empty) + authorIds.Add(access.OwnerId); + } + else if (!c.IsSystemComment && c.AuthorId != Guid.Empty) + { + authorIds.Add(c.AuthorId); + } + } + + IReadOnlyDictionary avatars = authorIds.Count > 0 + ? await _userService.GetUserAvatarsAsync(authorIds, cancellationToken) + : new Dictionary(); + + var dtos = items.Select(c => + { + string? avatarUrl; + if (c.IsGenesisComment) + { + avatars.TryGetValue(access.OwnerId, out avatarUrl); + } + else + { + avatars.TryGetValue(c.AuthorId, out avatarUrl); + } + + return new CommentDto( + c.Id, + c.TaskId, + c.AuthorId, + c.AuthorName, + avatarUrl, + c.Content, + c.CreatedAt, + c.UpdatedAt, + IsOwn: !c.IsSystemComment && c.AuthorId == userId, + IsEdited: c.IsEdited, + IsSystemComment: c.IsSystemComment, + IsGenesisComment: c.IsGenesisComment); + }).ToList(); + + return Result>.Success( + new PagedResult(dtos, request.PageNumber, request.PageSize, totalCount)); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs new file mode 100644 index 00000000..8986a17f --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs @@ -0,0 +1,58 @@ +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.IntegrationEvents +{ + /// + /// Appends the system comment for a task lifecycle transition (complete / start / leave), + /// which TodoApi used to write inline. The sentence templates live here — the event carries + /// only the structured fact and the actor's display name. + /// + public sealed class TaskActivityEventConsumer : IIntegrationEventHandler + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public TaskActivityEventConsumer( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ILogger logger) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task HandleAsync(TaskActivityIntegrationEvent @event, CancellationToken cancellationToken) + { + var actorName = string.IsNullOrWhiteSpace(@event.ActorName) ? "Someone" : @event.ActorName; + + var text = @event.ActivityType switch + { + TaskActivityType.Completed => $"{actorName} completed the task", + TaskActivityType.StartedWorking => $"{actorName} started working on the task", + TaskActivityType.Left => $"{actorName} left the task", + _ => null + }; + + if (text is null) + { + _logger.LogWarning( + "Unknown TaskActivityType '{ActivityType}' for task {TaskId} — skipping", + @event.ActivityType, @event.TaskId); + return; + } + + var systemComment = Comment.CreateSystem(@event.TaskId, text); + await _commentRepository.AddAsync(systemComment, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Appended '{ActivityType}' system comment for task {TaskId} by actor {ActorId}", + @event.ActivityType, @event.TaskId, @event.ActorId); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskCreatedEventConsumer.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskCreatedEventConsumer.cs new file mode 100644 index 00000000..af38642d --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskCreatedEventConsumer.cs @@ -0,0 +1,55 @@ +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.IntegrationEvents +{ + /// + /// Materialises the timeline entries TodoApi used to write inline on task creation: + /// the "{owner} created the task" system comment and the genesis comment (the task's + /// initial description), now driven by an integration event so TodoApi owns no comment code. + /// + public sealed class TaskCreatedEventConsumer : IIntegrationEventHandler + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public TaskCreatedEventConsumer( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ILogger logger) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task HandleAsync(TaskCreatedIntegrationEvent @event, CancellationToken cancellationToken) + { + var ownerName = string.IsNullOrWhiteSpace(@event.OwnerName) ? "Someone" : @event.OwnerName; + + var systemComment = Comment.CreateSystem(@event.TaskId, $"{ownerName} created the task"); + await _commentRepository.AddAsync(systemComment, cancellationToken); + + if (!string.IsNullOrWhiteSpace(@event.Description)) + { + // Idempotency guard: genesis is unique per task, so a redelivered event + // never produces a second description. + var existingGenesis = await _commentRepository.GetGenesisCommentAsync(@event.TaskId, cancellationToken); + if (existingGenesis is null) + { + var genesisComment = Comment.CreateGenesis(@event.TaskId, @event.Description!, ownerName); + await _commentRepository.AddAsync(genesisComment, cancellationToken); + } + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Materialised creation timeline for task {TaskId} (owner {OwnerId})", + @event.TaskId, @event.OwnerId); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskDeletedEventConsumer.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskDeletedEventConsumer.cs new file mode 100644 index 00000000..bb01ffe6 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskDeletedEventConsumer.cs @@ -0,0 +1,39 @@ +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.IntegrationEvents +{ + /// + /// Cascade-deletes a task's timeline when the task is deleted in TodoApi. Replaces the + /// former in-process soft-delete that TodoApi performed in the same transaction; now driven + /// by an integration event so TodoApi owns no comment code. Naturally idempotent — a + /// redelivered event simply finds no remaining non-deleted comments. + /// + public sealed class TaskDeletedEventConsumer : IIntegrationEventHandler + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public TaskDeletedEventConsumer( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ILogger logger) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task HandleAsync(TaskDeletedIntegrationEvent @event, CancellationToken cancellationToken) + { + await _commentRepository.SoftDeleteByTaskIdAsync(@event.TaskId, @event.ActorId, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Soft-deleted timeline for deleted task {TaskId} (actor {ActorId})", + @event.TaskId, @event.ActorId); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/UserDeletedEventConsumer.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/UserDeletedEventConsumer.cs new file mode 100644 index 00000000..222076fb --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/UserDeletedEventConsumer.cs @@ -0,0 +1,54 @@ +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.IntegrationEvents +{ + /// + /// Soft-deletes every comment authored by a user when that user is permanently deleted in + /// AuthApi, mirroring TodoApi's UserDeleted cleanup. Prevents orphaned authored content from + /// lingering in timelines after account deletion. Idempotent — already-deleted rows are + /// filtered out by the repository's soft-delete query filter. + /// + public sealed class UserDeletedEventConsumer : IIntegrationEventHandler + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public UserDeletedEventConsumer( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ILogger logger) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task HandleAsync(UserDeletedIntegrationEvent @event, CancellationToken cancellationToken) + { + var comments = await _commentRepository.FindAsync(c => c.AuthorId == @event.UserId, cancellationToken); + + if (comments.Count == 0) + { + _logger.LogInformation( + "No comments authored by deleted user {UserId} — nothing to clean up", + @event.UserId); + return; + } + + foreach (var comment in comments) + { + comment.MarkAsDeleted(@event.UserId); + _commentRepository.Update(comment); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Soft-deleted {Count} comments authored by deleted user {UserId}", + comments.Count, @event.UserId); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/GlobalUsings.cs b/Services/CollaborationApi/Planora.Collaboration.Application/GlobalUsings.cs new file mode 100644 index 00000000..47d2d45e --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/GlobalUsings.cs @@ -0,0 +1,16 @@ +// AutoMapper +global using AutoMapper; +// FluentValidation +global using FluentValidation; +// CQRS +global using Planora.BuildingBlocks.Application.CQRS; +// BuildingBlocks +global using Planora.BuildingBlocks.Domain.Interfaces; +// Collaboration Domain +global using Planora.Collaboration.Domain.Entities; +// MediatR +global using MediatR; +// Dependency Injection +global using Microsoft.Extensions.DependencyInjection; +// Logging +global using Microsoft.Extensions.Logging; diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Planora.Collaboration.Application.csproj b/Services/CollaborationApi/Planora.Collaboration.Application/Planora.Collaboration.Application.csproj new file mode 100644 index 00000000..47768ff1 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Planora.Collaboration.Application.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + latest + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Services/ITaskAccessService.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Services/ITaskAccessService.cs new file mode 100644 index 00000000..7dcdebf9 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Services/ITaskAccessService.cs @@ -0,0 +1,24 @@ +namespace Planora.Collaboration.Application.Services +{ + /// + /// Result of authorising a comment operation against a task owned by TodoApi. + /// + public sealed record TaskAccessResult( + bool Exists, + bool HasAccess, + Guid OwnerId, + IReadOnlyList ParticipantIds); + + /// + /// Delegates task-comment authorisation to TodoApi (which owns the task aggregate and the + /// ownership / sharing / public + friendship rules) over gRPC. The Collaboration service + /// never reads Todo's database (INV-OWN-1) and never needs to know the sharing model. + /// + public interface ITaskAccessService + { + Task CheckCommentAccessAsync( + Guid taskId, + Guid requesterId, + CancellationToken cancellationToken = default); + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs new file mode 100644 index 00000000..c8063416 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs @@ -0,0 +1,18 @@ +namespace Planora.Collaboration.Application.Services +{ + /// + /// Provides current user profile data from the Auth service. Used to enrich comment author + /// avatars. Ported from the former TodoApi.IUserService — identical contract. + /// + public interface IUserService + { + /// + /// Returns the current profile picture URLs for the given user IDs. + /// Users without a profile picture are omitted from the result. + /// Failures are swallowed — the caller should treat missing entries as "no avatar". + /// + Task> GetUserAvatarsAsync( + IEnumerable userIds, + CancellationToken cancellationToken = default); + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Domain/Entities/Comment.cs b/Services/CollaborationApi/Planora.Collaboration.Domain/Entities/Comment.cs new file mode 100644 index 00000000..8b3177ed --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Domain/Entities/Comment.cs @@ -0,0 +1,125 @@ +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Collaboration.Domain.Events; + +namespace Planora.Collaboration.Domain.Entities +{ + /// + /// A single entry in a task's activity timeline ("ветка"). Carries the same three flavours + /// the timeline has always had: a regular user comment, the genesis comment (the task's + /// initial description / author note) and auto-generated system event comments. Ported + /// verbatim from the former TodoApi.TodoItemComment — the only change is that the foreign + /// reference is named because this service owns no task aggregate. + /// + public sealed class Comment : BaseEntity, IAggregateRoot + { + public Guid TaskId { get; private set; } + public Guid AuthorId { get; private set; } + public string AuthorName { get; private set; } = string.Empty; + public string Content { get; private set; } = string.Empty; + public bool IsSystemComment { get; private set; } + public bool IsGenesisComment { get; private set; } + + public bool IsEdited => + (!IsSystemComment || IsGenesisComment) && UpdatedAt.HasValue && UpdatedAt.Value > CreatedAt.AddSeconds(5); + + private Comment() { } + + public static Comment Create( + Guid taskId, + Guid authorId, + string authorName, + string content) + { + if (taskId == Guid.Empty) + throw new InvalidValueObjectException(nameof(Comment), "TaskId cannot be empty"); + if (authorId == Guid.Empty) + throw new InvalidValueObjectException(nameof(Comment), "AuthorId cannot be empty"); + if (string.IsNullOrWhiteSpace(authorName)) + throw new InvalidValueObjectException(nameof(Comment), "AuthorName cannot be empty"); + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot be empty"); + if (content.Length > 2000) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot exceed 2000 characters"); + + var comment = new Comment + { + TaskId = taskId, + AuthorId = authorId, + AuthorName = authorName.Trim(), + Content = content.Trim(), + IsSystemComment = false, + }; + comment.AddDomainEvent(new CommentAddedDomainEvent(comment.Id, taskId, authorId)); + + return comment; + } + + public static Comment CreateSystem(Guid taskId, string content) + { + if (taskId == Guid.Empty) + throw new InvalidValueObjectException(nameof(Comment), "TaskId cannot be empty"); + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot be empty"); + + return new Comment + { + TaskId = taskId, + AuthorId = Guid.Empty, + AuthorName = string.Empty, + Content = content.Trim(), + IsSystemComment = true, + IsGenesisComment = false, + }; + } + + public static Comment CreateGenesis( + Guid taskId, + string content, + string authorName) + { + if (taskId == Guid.Empty) + throw new InvalidValueObjectException(nameof(Comment), "TaskId cannot be empty"); + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot be empty"); + if (content.Length > 5000) + throw new InvalidValueObjectException(nameof(Comment), "Description cannot exceed 5000 characters"); + + return new Comment + { + TaskId = taskId, + AuthorId = Guid.Empty, + AuthorName = string.IsNullOrWhiteSpace(authorName) ? string.Empty : authorName.Trim(), + Content = content.Trim(), + IsSystemComment = true, + IsGenesisComment = true, + }; + } + + public void UpdateGenesisContent(string content, Guid ownerUserId) + { + if (!IsGenesisComment) + throw new ForbiddenException("Only the genesis comment can be updated via this method"); + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot be empty"); + if (content.Length > 5000) + throw new InvalidValueObjectException(nameof(Comment), "Description cannot exceed 5000 characters"); + + Content = content.Trim(); + MarkAsModified(ownerUserId); + } + + public void UpdateContent(string content, Guid editorUserId) + { + if (editorUserId != AuthorId) + throw new ForbiddenException("Only the author can edit this comment"); + if (string.IsNullOrWhiteSpace(content)) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot be empty"); + if (content.Length > 2000) + throw new InvalidValueObjectException(nameof(Comment), "Content cannot exceed 2000 characters"); + + Content = content.Trim(); + MarkAsModified(editorUserId); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Domain/Events/CommentAddedDomainEvent.cs b/Services/CollaborationApi/Planora.Collaboration.Domain/Events/CommentAddedDomainEvent.cs new file mode 100644 index 00000000..31fc7230 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Domain/Events/CommentAddedDomainEvent.cs @@ -0,0 +1,9 @@ +using Planora.BuildingBlocks.Domain; + +namespace Planora.Collaboration.Domain.Events +{ + public sealed record CommentAddedDomainEvent( + Guid CommentId, + Guid TaskId, + Guid AuthorId) : DomainEvent; +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Domain/GlobalUsings.cs b/Services/CollaborationApi/Planora.Collaboration.Domain/GlobalUsings.cs new file mode 100644 index 00000000..8ac41261 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Domain/GlobalUsings.cs @@ -0,0 +1,2 @@ +// BuildingBlocks +global using Planora.BuildingBlocks.Domain.Interfaces; diff --git a/Services/CollaborationApi/Planora.Collaboration.Domain/Planora.Collaboration.Domain.csproj b/Services/CollaborationApi/Planora.Collaboration.Domain/Planora.Collaboration.Domain.csproj new file mode 100644 index 00000000..39c402c1 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Domain/Planora.Collaboration.Domain.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + latest + + + + + + + diff --git a/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs b/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs new file mode 100644 index 00000000..c6447881 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs @@ -0,0 +1,13 @@ +using Planora.BuildingBlocks.Domain.Interfaces; +using Planora.Collaboration.Domain.Entities; + +namespace Planora.Collaboration.Domain.Repositories +{ + public interface ICommentRepository : IRepository + { + Task<(IReadOnlyList Items, int TotalCount)> GetPagedByTaskIdAsync( + Guid taskId, int pageNumber, int pageSize, CancellationToken ct = default); + Task SoftDeleteByTaskIdAsync(Guid taskId, Guid deletedBy, CancellationToken ct = default); + Task GetGenesisCommentAsync(Guid taskId, CancellationToken ct = default); + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/DependencyInjection.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..3ce2e9c2 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/DependencyInjection.cs @@ -0,0 +1,78 @@ +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Outbox; +using Planora.BuildingBlocks.Infrastructure.Grpc; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Infrastructure.Grpc; +using Planora.Collaboration.Infrastructure.Persistence.Repositories; +using Microsoft.Extensions.Caching.Memory; + +namespace Planora.Collaboration.Infrastructure +{ + public static class DependencyInjection + { + public static IServiceCollection AddCollaborationInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("CollaborationDatabase") + ?? throw new InvalidOperationException("CollaborationDatabase connection string not found"); + + services.AddDbContext(options => + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(5), + errorCodesToAdd: null); + }) + .EnableSensitiveDataLogging(false)); + + // Register CollaborationDbContext as DbContext for the OutboxProcessor. + services.AddScoped(sp => sp.GetRequiredService()); + + // Repositories & Unit of Work + services.AddScoped(); + services.AddScoped(); + services.AddScoped, CommentRepository>(); + services.AddScoped(); + + // Outbox Processor — ships NotificationEvent to RabbitMQ (INV-COMM-3). + services.AddHostedService(); + + // Current user context + services.AddHttpContextAccessor(); + services.AddScoped(); + + // gRPC clients — both carry x-service-key via the shared interceptor (INV-COMM-2). + services.AddSingleton(); + + // Task access authorisation → TodoApi gRPC (port 5282 local / env-configurable). + var todoGrpcUrl = configuration["GrpcServices:TodoApi"] + ?? configuration["Services:Todo:Url"] + ?? "http://localhost:5101"; + services.AddGrpcClient(o => + o.Address = new Uri(todoGrpcUrl)) + .AddInterceptor(); + services.AddScoped(); + + // Avatar enrichment → AuthApi gRPC, wrapped in an in-memory cache so paged comment + // reads do not hammer Auth for the same authors repeatedly. + var authGrpcUrl = configuration["GrpcServices:AuthApi"] + ?? configuration["Services:Auth:Url"] + ?? "http://localhost:5031"; + services.AddGrpcClient(o => + o.Address = new Uri(authGrpcUrl)) + .AddInterceptor(); + services.AddScoped(); + services.AddScoped(sp => new CachingUserService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + services.AddHealthChecks() + .AddDbContextCheck("collaboration-dbcontext"); + + return services; + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/DesignTime/CollaborationDbContextFactory.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/DesignTime/CollaborationDbContextFactory.cs new file mode 100644 index 00000000..456fa6db --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/DesignTime/CollaborationDbContextFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Design; + +namespace Planora.Collaboration.Infrastructure.DesignTime +{ + internal sealed class CollaborationDbContextFactory : IDesignTimeDbContextFactory + { + public CollaborationDbContext CreateDbContext(string[] args) + { + var basePath = Directory.GetCurrentDirectory(); + var builder = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables(); + + var configuration = builder.Build(); + var conn = configuration.GetConnectionString("CollaborationDatabase") + ?? Environment.GetEnvironmentVariable("ConnectionStrings__CollaborationDatabase") + ?? "Host=localhost;Port=5433;Database=planora_collaboration;Username=postgres;Password=postgres"; + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(conn); + + return new CollaborationDbContext(optionsBuilder.Options); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/GlobalUsings.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/GlobalUsings.cs new file mode 100644 index 00000000..ca2872e0 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/GlobalUsings.cs @@ -0,0 +1,19 @@ +// BuildingBlocks +global using Planora.BuildingBlocks.Domain.Interfaces; +// Collaboration Domain & Infrastructure +global using Planora.Collaboration.Domain.Entities; +global using Planora.Collaboration.Domain.Repositories; +global using Planora.Collaboration.Infrastructure.Persistence; +global using Planora.Collaboration.Infrastructure.Persistence.Repositories; +// EntityFrameworkCore +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Design; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.EntityFrameworkCore.Storage; +// Grpc +global using Planora.GrpcContracts; +// Logging +global using Microsoft.Extensions.Logging; +// Configuration & DI +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs new file mode 100644 index 00000000..3a71adb7 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Caching.Memory; +using Planora.Collaboration.Application.Services; + +namespace Planora.Collaboration.Infrastructure.Grpc +{ + /// + /// In-memory cache wrapper around . Comment listings page through + /// the same authors repeatedly; without caching each page hit Auth gRPC. A 60 s TTL bounds + /// staleness when a user changes their avatar while keeping the cost of comment reads low. + /// Ported from the former TodoApi.CachingUserService. + /// + public sealed class CachingUserService : IUserService + { + private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(60); + private const string KeyPrefix = "collaboration:user-avatar:"; + + private readonly IUserService _inner; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public CachingUserService( + IUserService inner, + IMemoryCache cache, + ILogger logger) + { + _inner = inner; + _cache = cache; + _logger = logger; + } + + public async Task> GetUserAvatarsAsync( + IEnumerable userIds, + CancellationToken cancellationToken = default) + { + var ids = userIds.Distinct().ToList(); + if (ids.Count == 0) + { + return new Dictionary(); + } + + var result = new Dictionary(ids.Count); + var missing = new List(); + + foreach (var id in ids) + { + if (_cache.TryGetValue(Key(id), out CacheEntry? entry) && entry is not null) + { + if (entry.Url is not null) + { + result[id] = entry.Url; + } + } + else + { + missing.Add(id); + } + } + + if (missing.Count == 0) + { + return result; + } + + var fresh = await _inner.GetUserAvatarsAsync(missing, cancellationToken); + + foreach (var id in missing) + { + fresh.TryGetValue(id, out var url); + _cache.Set(Key(id), new CacheEntry(url), new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = Ttl, + Size = 1, + }); + if (url is not null) + { + result[id] = url; + } + } + + _logger.LogDebug( + "Avatar cache miss: {MissCount}/{TotalCount} fetched from Auth gRPC", + missing.Count, ids.Count); + + return result; + } + + private static string Key(Guid id) => KeyPrefix + id.ToString("N"); + + private sealed record CacheEntry(string? Url); + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs new file mode 100644 index 00000000..95e52147 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs @@ -0,0 +1,63 @@ +using Grpc.Core; +using Planora.Collaboration.Application.Services; +using Planora.GrpcContracts; + +namespace Planora.Collaboration.Infrastructure.Grpc +{ + /// + /// Authorises comment operations by delegating to TodoApi over gRPC. TodoApi owns the task + /// aggregate and the ownership / sharing / public + friendship rules; this client never reads + /// Todo's database (INV-OWN-1). The x-service-key header is injected by the shared + /// registered on the channel (INV-COMM-2). + /// + public sealed class TaskAccessGrpcClient : ITaskAccessService + { + private readonly TodoService.TodoServiceClient _client; + private readonly ILogger _logger; + + public TaskAccessGrpcClient( + TodoService.TodoServiceClient client, + ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CheckCommentAccessAsync( + Guid taskId, + Guid requesterId, + CancellationToken cancellationToken = default) + { + try + { + var response = await _client.CheckTaskCommentAccessAsync( + new CheckTaskCommentAccessRequest + { + TaskId = taskId.ToString(), + RequesterId = requesterId.ToString(), + }, + cancellationToken: cancellationToken); + + var participants = response.ParticipantIds + .Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + Guid.TryParse(response.OwnerId, out var ownerId); + + return new TaskAccessResult(response.Exists, response.HasAccess, ownerId, participants); + } + catch (RpcException ex) + { + _logger.LogWarning( + ex, + "Todo gRPC failed while checking comment access for task {TaskId}, requester {RequesterId}: Status={Status}", + taskId, requesterId, ex.StatusCode); + // Fail closed: no access decision available → deny. Treated as "exists, no access" + // so callers surface 403 rather than 404 when Todo is transiently unavailable. + throw; + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs new file mode 100644 index 00000000..0151f598 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs @@ -0,0 +1,63 @@ +using Grpc.Core; +using Planora.Collaboration.Application.Services; +using Planora.GrpcContracts; + +namespace Planora.Collaboration.Infrastructure.Grpc +{ + public sealed class UserGrpcService : IUserService + { + private readonly AuthService.AuthServiceClient _client; + private readonly ILogger _logger; + + public UserGrpcService( + AuthService.AuthServiceClient client, + ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> GetUserAvatarsAsync( + IEnumerable userIds, + CancellationToken cancellationToken = default) + { + var ids = userIds.Distinct().ToList(); + if (ids.Count == 0) + return new Dictionary(); + + try + { + var request = new GetUserAvatarsBatchRequest(); + request.UserIds.AddRange(ids.Select(id => id.ToString())); + + var response = await _client.GetUserAvatarsBatchAsync( + request, + cancellationToken: cancellationToken); + + var result = new Dictionary(); + foreach (var kvp in response.AvatarUrls) + { + if (Guid.TryParse(kvp.Key, out var guid) && !string.IsNullOrEmpty(kvp.Value)) + result[guid] = kvp.Value; + } + + return result; + } + catch (RpcException ex) + { + _logger.LogWarning( + ex, + "Auth gRPC unavailable while fetching avatars for {Count} users: Status={Status}", + ids.Count, + ex.StatusCode); + // Non-fatal — comment thread still loads, just without live avatars + return new Dictionary(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch user avatars from Auth gRPC"); + return new Dictionary(); + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/CollaborationDbContext.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/CollaborationDbContext.cs new file mode 100644 index 00000000..25349349 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/CollaborationDbContext.cs @@ -0,0 +1,34 @@ +using Planora.BuildingBlocks.Application.Outbox; + +namespace Planora.Collaboration.Infrastructure.Persistence +{ + public sealed class CollaborationDbContext : DbContext + { + public CollaborationDbContext(DbContextOptions options) : base(options) { } + + public DbSet Comments => Set(); + public DbSet OutboxMessages => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CollaborationDbContext).Assembly); + + modelBuilder.HasDefaultSchema("collaboration"); + + // Optimistic concurrency for the Comment aggregate via PostgreSQL's xmin system + // column — no extra column or migration. Guarded so the InMemory test provider + // (used in unit tests) is unaffected. + if (Database.IsNpgsql()) + { + modelBuilder.Entity() + .Property("xmin") + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs new file mode 100644 index 00000000..13aa454e --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs @@ -0,0 +1,39 @@ +namespace Planora.Collaboration.Infrastructure.Persistence.Configurations +{ + public sealed class CommentConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("comments"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.TaskId).IsRequired(); + builder.Property(x => x.AuthorId).IsRequired(); + + builder.Property(x => x.AuthorName) + .IsRequired() + .HasMaxLength(200); + + builder.Property(x => x.Content) + .IsRequired() + .HasMaxLength(5000); + + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(false); + builder.Property(x => x.DeletedAt).IsRequired(false); + builder.Property(x => x.IsDeleted).HasDefaultValue(false); + + builder.Property(x => x.IsSystemComment) + .IsRequired() + .HasDefaultValue(false); + + builder.Property(x => x.IsGenesisComment) + .IsRequired() + .HasDefaultValue(false); + + // Optimised for timeline reads (ordered by creation per task). + builder.HasIndex(x => new { x.TaskId, x.CreatedAt }); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs new file mode 100644 index 00000000..fd11a177 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs @@ -0,0 +1,40 @@ +using Planora.BuildingBlocks.Application.Outbox; + +namespace Planora.Collaboration.Infrastructure.Persistence.Configurations +{ + public sealed class OutboxMessageConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OutboxMessages"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Type) + .IsRequired() + .HasMaxLength(255); + + builder.Property(x => x.Content) + .IsRequired(); + + builder.Property(x => x.OccurredOnUtc) + .IsRequired(); + + builder.Property(x => x.ProcessedOnUtc); + + builder.Property(x => x.Status) + .IsRequired() + .HasConversion(); + + builder.Property(x => x.Error) + .HasMaxLength(2000); + + builder.Property(x => x.RetryCount) + .IsRequired() + .HasDefaultValue(0); + + builder.HasIndex(x => new { x.Status, x.OccurredOnUtc }); + builder.HasIndex(x => x.ProcessedOnUtc); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CollaborationUnitOfWork.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CollaborationUnitOfWork.cs new file mode 100644 index 00000000..7e261169 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CollaborationUnitOfWork.cs @@ -0,0 +1,70 @@ +namespace Planora.Collaboration.Infrastructure.Persistence.Repositories +{ + public sealed class CollaborationUnitOfWork : IUnitOfWork + { + private readonly CollaborationDbContext _context; + private IDbContextTransaction? _transaction; + + public bool HasActiveTransaction => _transaction != null; + + public CollaborationUnitOfWork(CollaborationDbContext context) + { + _context = context; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _context.SaveChangesAsync(cancellationToken); + } + + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + _transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + } + + public async Task CommitTransactionAsync(CancellationToken cancellationToken = default) + { + try + { + await _context.SaveChangesAsync(cancellationToken); + if (_transaction != null) + await _transaction.CommitAsync(cancellationToken); + } + finally + { + if (_transaction != null) + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default) + { + try + { + if (_transaction != null) + await _transaction.RollbackAsync(cancellationToken); + } + finally + { + if (_transaction != null) + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + public void Dispose() + { + _transaction?.Dispose(); + _context.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (_transaction != null) + await _transaction.DisposeAsync(); + + await _context.DisposeAsync(); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs new file mode 100644 index 00000000..014296df --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs @@ -0,0 +1,59 @@ +using Planora.BuildingBlocks.Application.Pagination; +using Planora.BuildingBlocks.Infrastructure.Persistence; + +namespace Planora.Collaboration.Infrastructure.Persistence.Repositories +{ + public sealed class CommentRepository + : BaseRepository, ICommentRepository + { + public CommentRepository(CollaborationDbContext context) : base(context) { } + + public override async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await DbSet + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, cancellationToken); + } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedByTaskIdAsync( + Guid taskId, int pageNumber, int pageSize, CancellationToken ct = default) + { + var (safePageNumber, safePageSize) = PaginationParameters.Normalize(pageNumber, pageSize); + + var query = DbSet + .AsNoTracking() + .Where(c => c.TaskId == taskId && !c.IsDeleted); + + var totalCount = await query.CountAsync(ct); + + var items = await query + .OrderBy(c => c.CreatedAt) + .Skip((safePageNumber - 1) * safePageSize) + .Take(safePageSize) + .ToListAsync(ct); + + return (items, totalCount); + } + + public async Task GetGenesisCommentAsync(Guid taskId, CancellationToken ct = default) + { + return await DbSet + .AsNoTracking() + .FirstOrDefaultAsync(c => c.TaskId == taskId && c.IsGenesisComment && !c.IsDeleted, ct); + } + + public async Task SoftDeleteByTaskIdAsync(Guid taskId, Guid deletedBy, CancellationToken ct = default) + { + // Load-then-update instead of ExecuteUpdateAsync: works with all EF Core providers + // including InMemory (used in unit tests). Comment counts per task are bounded, so + // the extra round-trip is negligible. Changes are flushed by the caller's + // UnitOfWork.SaveChangesAsync(). + var comments = await DbSet + .Where(c => c.TaskId == taskId && !c.IsDeleted) + .ToListAsync(ct); + + foreach (var comment in comments) + comment.MarkAsDeleted(deletedBy); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/OutboxRepository.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/OutboxRepository.cs new file mode 100644 index 00000000..6f8fe198 --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/OutboxRepository.cs @@ -0,0 +1,46 @@ +using Planora.BuildingBlocks.Application.Outbox; + +namespace Planora.Collaboration.Infrastructure.Persistence.Repositories +{ + public sealed class OutboxRepository : IOutboxRepository + { + private readonly CollaborationDbContext _context; + + public OutboxRepository(CollaborationDbContext context) + { + _context = context; + } + + public async Task AddAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + await _context.OutboxMessages.AddAsync(message, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task> GetPendingMessagesAsync(int batchSize, CancellationToken cancellationToken = default) + { + return await _context.OutboxMessages + .Where(m => m.Status == OutboxMessageStatus.Pending || + (m.Status == OutboxMessageStatus.Failed && m.NextRetryUtc <= System.DateTime.UtcNow)) + .OrderBy(m => m.OccurredOnUtc) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task UpdateAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + _context.OutboxMessages.Update(message); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteProcessedMessagesAsync(DateTime olderThan, CancellationToken cancellationToken = default) + { + var messagesToDelete = await _context.OutboxMessages + .Where(m => m.Status == OutboxMessageStatus.Processed && m.ProcessedOnUtc < olderThan) + .ToListAsync(cancellationToken); + + _context.OutboxMessages.RemoveRange(messagesToDelete); + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Planora.Collaboration.Infrastructure.csproj b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Planora.Collaboration.Infrastructure.csproj new file mode 100644 index 00000000..dcdd701f --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Planora.Collaboration.Infrastructure.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + latest + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/TodoApi/Planora.Todo.Api/appsettings.Docker.json b/Services/TodoApi/Planora.Todo.Api/appsettings.Docker.json index 1cf420c8..d8a5706d 100644 --- a/Services/TodoApi/Planora.Todo.Api/appsettings.Docker.json +++ b/Services/TodoApi/Planora.Todo.Api/appsettings.Docker.json @@ -29,8 +29,13 @@ }, "Kestrel": { "Endpoints": { - "Http": { - "Url": "http://*:80" + "Rest": { + "Url": "http://*:80", + "Protocols": "Http1" + }, + "Grpc": { + "Url": "http://*:81", + "Protocols": "Http2" } } } diff --git a/docker-compose.yml b/docker-compose.yml index 60d166c8..b047b6ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -204,9 +204,10 @@ services: container_name: planora-todo-api ports: - "127.0.0.1:5100:80" + - "127.0.0.1:5101:81" environment: ASPNETCORE_ENVIRONMENT: Docker - ASPNETCORE_URLS: "http://+:80" + ASPNETCORE_URLS: "http://+:80;http://+:81" ConnectionStrings__TodoDatabase: "Host=postgres;Port=5432;Database=planora_todo;Username=postgres;Password=${POSTGRES_PASSWORD};" RateLimiting__Backend: Redis ConnectionStrings__Redis: "redis:6379,password=${REDIS_PASSWORD:?REDIS_PASSWORD env var must be set}" @@ -238,6 +239,48 @@ services: - planora-network restart: on-failure + # Collaboration API (task comment timeline / "ветки" + comment notifications) + collaboration-api: + build: + context: . + dockerfile: Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile + container_name: planora-collaboration-api + ports: + - "127.0.0.1:5060:80" + environment: + ASPNETCORE_ENVIRONMENT: Docker + ASPNETCORE_URLS: "http://+:80" + ConnectionStrings__CollaborationDatabase: "Host=postgres;Port=5432;Database=planora_collaboration;Username=postgres;Password=${POSTGRES_PASSWORD};" + RateLimiting__Backend: Redis + ConnectionStrings__Redis: "redis:6379,password=${REDIS_PASSWORD:?REDIS_PASSWORD env var must be set}" + RabbitMq__HostName: "rabbitmq" + RabbitMq__UserName: ${RABBITMQ_USER:?RABBITMQ_USER env var must be set} + RabbitMq__Password: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD env var must be set} + JwtSettings__Secret: ${JWT_SECRET:?JWT_SECRET env var must be set} + JwtSettings__Issuer: "Planora.Auth" + JwtSettings__Audience: "Planora.Clients" + GrpcServices__AuthApi: "http://auth-api:80" + GrpcServices__TodoApi: "http://todo-api:81" + GrpcSettings__ServiceKey: ${GRPC_SERVICE_KEY:?GRPC_SERVICE_KEY env var must be set} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + todo-api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - planora-network + restart: on-failure + # Realtime API (SignalR) realtime-api: build: diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 183427f4..22fde92e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -312,7 +312,7 @@ export const fetchComments = async ( pageSize = 50, ): Promise => { const { data } = await api.get>( - `/todos/api/v1/todos/${todoId}/comments`, + `/collaboration/api/v1/comments/${todoId}`, { params: { pageNumber, pageSize } }, ) return parseApiResponse(data) @@ -320,7 +320,7 @@ export const fetchComments = async ( export const addComment = async (todoId: string, content: string): Promise => { const { data } = await api.post>( - `/todos/api/v1/todos/${todoId}/comments`, + `/collaboration/api/v1/comments/${todoId}`, { content }, ) return parseApiResponse(data) @@ -332,19 +332,19 @@ export const updateComment = async ( content: string, ): Promise => { const { data } = await api.put>( - `/todos/api/v1/todos/${todoId}/comments/${commentId}`, + `/collaboration/api/v1/comments/${todoId}/${commentId}`, { content }, ) return parseApiResponse(data) } export const deleteComment = async (todoId: string, commentId: string): Promise => { - await api.delete(`/todos/api/v1/todos/${todoId}/comments/${commentId}`) + await api.delete(`/collaboration/api/v1/comments/${todoId}/${commentId}`) } export const addGenesisComment = async (todoId: string, content: string): Promise => { const { data } = await api.post>( - `/todos/api/v1/todos/${todoId}/genesis`, + `/collaboration/api/v1/comments/${todoId}/genesis`, { content }, ) return parseApiResponse(data) diff --git a/tools/Planora.Migrator/Planora.Migrator.csproj b/tools/Planora.Migrator/Planora.Migrator.csproj index dfd45ccf..7b21e2ba 100644 --- a/tools/Planora.Migrator/Planora.Migrator.csproj +++ b/tools/Planora.Migrator/Planora.Migrator.csproj @@ -29,6 +29,7 @@ + diff --git a/tools/Planora.Migrator/Program.cs b/tools/Planora.Migrator/Program.cs index f4093834..8c22c31b 100644 --- a/tools/Planora.Migrator/Program.cs +++ b/tools/Planora.Migrator/Program.cs @@ -7,6 +7,7 @@ using Planora.BuildingBlocks.Domain.Interfaces; using Planora.Auth.Infrastructure.Persistence; using Planora.Category.Infrastructure.Persistence; +using Planora.Collaboration.Infrastructure.Persistence; using Planora.Messaging.Infrastructure.Persistence; using Planora.Todo.Infrastructure.Persistence; @@ -39,6 +40,7 @@ internal static class Program new("category", "CategoryDatabase", typeof(CategoryDbContext), RequiresDispatcher: true), new("todo", "TodoDatabase", typeof(TodoDbContext), RequiresDispatcher: false), new("messaging", "MessagingDatabase", typeof(MessagingDbContext), RequiresDispatcher: false), + new("collaboration", "CollaborationDatabase", typeof(CollaborationDbContext), RequiresDispatcher: false), ]; public static async Task Main(string[] args) From 949e4e8b60f175a0cc740afc37c5a822b1e43de2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:01:05 +0000 Subject: [PATCH 02/14] refactor(todo): remove all comment/branch functionality, publish lifecycle events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TodoApi no longer owns any 'ветки' code. The comment entity, repository, DTO, 5 CQRS handlers, avatar gRPC client and REST endpoints are deleted. Task lifecycle now drives the Collaboration timeline via integration events published through a new Todo outbox (INV-COMM-3): - CreateTodo -> TaskCreatedIntegrationEvent (system 'created' + genesis) - Update/Join/Leave -> TaskActivityIntegrationEvent (completed/started/left) - DeleteTodo -> TaskDeletedIntegrationEvent (cascade soft-delete) - TodoGrpcService.CheckTaskCommentAccess exposes the task access decision (owner/shared/public + friendship) + participants for Collaboration - TodoDbContext gains OutboxMessages; OutboxProcessor registered; Todo gRPC endpoint published on :81 in Docker - EF migration drops todo.todo_item_comments and creates todo.OutboxMessages - Tests: comment-only suites removed, handler fixtures switched to the outbox ctor, Comment domain tests ported to Collaboration, arch tests cover it --- .../Controllers/TodosController.cs | 85 --- .../Planora.Todo.Api/Grpc/TodoGrpcService.cs | 56 +- .../Common/OutboxExtensions.cs | 28 + .../DTOs/TodoCommentDto.cs | 17 - .../Commands/AddComment/AddCommentCommand.cs | 7 - .../AddComment/AddCommentCommandHandler.cs | 84 --- .../AddComment/AddCommentCommandValidator.cs | 13 - .../AddGenesisCommentCommand.cs | 7 - .../AddGenesisCommentCommandHandler.cs | 72 -- .../CreateTodo/CreateTodoCommandHandler.cs | 23 +- .../DeleteComment/DeleteCommentCommand.cs | 6 - .../DeleteCommentCommandHandler.cs | 59 -- .../DeleteTodo/DeleteTodoCommandHandler.cs | 18 +- .../JoinTodo/JoinTodoCommandHandler.cs | 14 +- .../LeaveTodo/LeaveTodoCommandHandler.cs | 14 +- .../UpdateComment/UpdateCommentCommand.cs | 7 - .../UpdateCommentCommandHandler.cs | 85 --- .../UpdateCommentCommandValidator.cs | 13 - .../UpdateTodo/UpdateTodoCommandHandler.cs | 29 +- .../Queries/GetComments/GetCommentsQuery.cs | 12 - .../GetComments/GetCommentsQueryHandler.cs | 116 --- .../Services/IUserService.cs | 18 - .../Entities/TodoItemComment.cs | 118 --- .../Events/TodoCommentAddedDomainEvent.cs | 9 - .../Repositories/ITodoCommentRepository.cs | 13 - .../DependencyInjection.cs | 24 +- .../Migrations/TodoDbContextModelSnapshot.cs | 64 +- .../OutboxMessageConfiguration.cs | 40 + .../TodoItemCommentConfiguration.cs | 43 -- .../Repositories/OutboxRepository.cs | 46 ++ .../Repositories/TodoCommentRepository.cs | 61 -- .../Persistence/TodoDbContext.cs | 3 +- .../Services/CachingUserService.cs | 95 --- .../Services/UserGrpcService.cs | 64 -- .../Architecture/ArchitectureTests.cs | 4 + .../Planora.UnitTests.csproj | 4 + .../Domain/CommentTests.cs} | 118 +-- .../TodosWorkerCommentControllerTests.cs | 337 --------- .../TodoCommandHandlerExpandedTests.cs | 8 +- .../Handlers/TodoOwnershipHandlerTests.cs | 6 +- .../WorkersAndCommentsHandlerTests.cs | 682 ------------------ .../Infrastructure/CachingUserServiceTests.cs | 100 --- 42 files changed, 351 insertions(+), 2271 deletions(-) create mode 100644 Services/TodoApi/Planora.Todo.Application/Common/OutboxExtensions.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/DTOs/TodoCommentDto.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommand.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandHandler.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommand.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommand.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommand.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQuery.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs delete mode 100644 Services/TodoApi/Planora.Todo.Application/Services/IUserService.cs delete mode 100644 Services/TodoApi/Planora.Todo.Domain/Entities/TodoItemComment.cs delete mode 100644 Services/TodoApi/Planora.Todo.Domain/Events/TodoCommentAddedDomainEvent.cs delete mode 100644 Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoCommentRepository.cs create mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs delete mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs create mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/OutboxRepository.cs delete mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoCommentRepository.cs delete mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs delete mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs rename tests/Planora.UnitTests/Services/{TodoApi/Domain/TodoItemCommentTests.cs => CollaborationApi/Domain/CommentTests.cs} (55%) delete mode 100644 tests/Planora.UnitTests/Services/TodoApi/Controllers/TodosWorkerCommentControllerTests.cs delete mode 100644 tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs delete mode 100644 tests/Planora.UnitTests/Services/TodoApi/Infrastructure/CachingUserServiceTests.cs diff --git a/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs b/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs index 2601b3d1..f2ae59ae 100644 --- a/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs +++ b/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs @@ -12,11 +12,6 @@ using Planora.Todo.Application.Features.Todos.Queries.GetTodoById; using Planora.Todo.Application.Features.Todos.Commands.JoinTodo; using Planora.Todo.Application.Features.Todos.Commands.LeaveTodo; -using Planora.Todo.Application.Features.Todos.Commands.AddComment; -using Planora.Todo.Application.Features.Todos.Commands.AddGenesisComment; -using Planora.Todo.Application.Features.Todos.Commands.UpdateComment; -using Planora.Todo.Application.Features.Todos.Commands.DeleteComment; -using Planora.Todo.Application.Features.Todos.Queries.GetComments; namespace Planora.Todo.Api.Controllers { @@ -201,83 +196,6 @@ public async Task LeaveTodo( return BadRequest(result.Error); return NoContent(); } - - [HttpGet("{id}/comments")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetComments( - [FromRoute] Guid id, - [FromQuery] int pageNumber = 1, - [FromQuery] int pageSize = 50, - CancellationToken cancellationToken = default) - { - var result = await _mediator.Send(new GetCommentsQuery(id, pageNumber, pageSize), cancellationToken); - if (result.IsFailure) - return BadRequest(result.Error); - return Ok(result.Value); - } - - [HttpPost("{id}/genesis")] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task> AddGenesisComment( - [FromRoute] Guid id, - [FromBody] AddGenesisCommentRequest request, - CancellationToken cancellationToken = default) - { - var result = await _mediator.Send(new AddGenesisCommentCommand(id, request.Content), cancellationToken); - if (result.IsFailure) - return BadRequest(result.Error); - return StatusCode(StatusCodes.Status201Created, result.Value); - } - - [HttpPost("{id}/comments")] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task> AddComment( - [FromRoute] Guid id, - [FromBody] AddCommentRequest request, - CancellationToken cancellationToken = default) - { - var result = await _mediator.Send(new AddCommentCommand(id, request.Content), cancellationToken); - if (result.IsFailure) - return BadRequest(result.Error); - return StatusCode(StatusCodes.Status201Created, result.Value); - } - - [HttpPut("{id}/comments/{commentId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> UpdateComment( - [FromRoute] Guid id, - [FromRoute] Guid commentId, - [FromBody] UpdateCommentRequest request, - CancellationToken cancellationToken = default) - { - var result = await _mediator.Send(new UpdateCommentCommand(id, commentId, request.Content), cancellationToken); - if (result.IsFailure) - return BadRequest(result.Error); - return Ok(result.Value); - } - - [HttpDelete("{id}/comments/{commentId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteComment( - [FromRoute] Guid id, - [FromRoute] Guid commentId, - CancellationToken cancellationToken = default) - { - var result = await _mediator.Send(new DeleteCommentCommand(id, commentId), cancellationToken); - if (result.IsFailure) - return BadRequest(result.Error); - return NoContent(); - } } } @@ -287,6 +205,3 @@ public sealed record SetViewerPreferenceRequest( Guid? ViewerCategoryId = null, bool UpdateViewerCategory = false, bool? CompletedByViewer = null); -public sealed record AddGenesisCommentRequest(string Content); -public sealed record AddCommentRequest(string Content); -public sealed record UpdateCommentRequest(string Content); diff --git a/Services/TodoApi/Planora.Todo.Api/Grpc/TodoGrpcService.cs b/Services/TodoApi/Planora.Todo.Api/Grpc/TodoGrpcService.cs index 834d2687..78ccc93d 100644 --- a/Services/TodoApi/Planora.Todo.Api/Grpc/TodoGrpcService.cs +++ b/Services/TodoApi/Planora.Todo.Api/Grpc/TodoGrpcService.cs @@ -5,6 +5,8 @@ using Planora.Todo.Application.Features.Todos.Commands.UpdateTodo; using Planora.Todo.Application.Features.Todos.Queries.GetTodosByCategory; using Planora.Todo.Application.Features.Todos.Queries.GetUserTodos; +using Planora.Todo.Application.Services; +using Planora.Todo.Domain.Repositories; using MediatR; namespace Planora.Todo.Api.Grpc; @@ -12,14 +14,66 @@ namespace Planora.Todo.Api.Grpc; public class TodoGrpcService : TodoService.TodoServiceBase { private readonly IMediator _mediator; + private readonly ITodoRepository _todoRepository; + private readonly IFriendshipService _friendshipService; private readonly ILogger _logger; - public TodoGrpcService(IMediator mediator, ILogger logger) + public TodoGrpcService( + IMediator mediator, + ITodoRepository todoRepository, + IFriendshipService friendshipService, + ILogger logger) { _mediator = mediator; + _todoRepository = todoRepository; + _friendshipService = friendshipService; _logger = logger; } + /// + /// Authorises a comment/timeline operation for a task. Encapsulates the exact + /// ownership / sharing / public + friendship rules the comment handlers used to apply, + /// so the Collaboration service never reads Todo's database (INV-OWN-1). + /// + public override async Task CheckTaskCommentAccess( + CheckTaskCommentAccessRequest request, ServerCallContext context) + { + if (!Guid.TryParse(request.TaskId, out var taskId) || + !Guid.TryParse(request.RequesterId, out var requesterId)) + { + throw new RpcException(new global::Grpc.Core.Status( + global::Grpc.Core.StatusCode.InvalidArgument, "task_id and requester_id must be valid GUIDs")); + } + + var todoItem = await _todoRepository.GetByIdWithIncludesAsync(taskId, context.CancellationToken); + if (todoItem is null) + { + return new CheckTaskCommentAccessResponse { Exists = false, HasAccess = false, OwnerId = string.Empty }; + } + + var isOwner = todoItem.UserId == requesterId; + var isSharedDirectly = todoItem.SharedWith.Any(s => s.SharedWithUserId == requesterId); + var hasVisibility = todoItem.IsPublic || isSharedDirectly; + var isFriend = hasVisibility && !isOwner + && await _friendshipService.AreFriendsAsync(requesterId, todoItem.UserId, context.CancellationToken); + var hasAccess = isOwner || (isSharedDirectly && isFriend) || (todoItem.IsPublic && isFriend); + + var response = new CheckTaskCommentAccessResponse + { + Exists = true, + HasAccess = hasAccess, + OwnerId = todoItem.UserId.ToString(), + }; + + // Notification recipients: owner + workers + shared-with audience. + var participants = new HashSet { todoItem.UserId }; + foreach (var w in todoItem.Workers) participants.Add(w.UserId); + foreach (var s in todoItem.SharedWith) participants.Add(s.SharedWithUserId); + response.ParticipantIds.AddRange(participants.Where(id => id != Guid.Empty).Select(id => id.ToString())); + + return response; + } + public override async Task GetUserTodos(GetUserTodosRequest request, ServerCallContext context) { var query = new GetUserTodosQuery( diff --git a/Services/TodoApi/Planora.Todo.Application/Common/OutboxExtensions.cs b/Services/TodoApi/Planora.Todo.Application/Common/OutboxExtensions.cs new file mode 100644 index 00000000..66081f18 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Application/Common/OutboxExtensions.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Outbox; + +namespace Planora.Todo.Application.Common +{ + /// + /// Writes integration events into the service outbox inside the caller's unit of work + /// (INV-COMM-3). The shared ships them to RabbitMQ. Used by + /// the task lifecycle handlers to drive the Collaboration timeline ("ветки") instead of the + /// former in-process comment writes. + /// + internal static class OutboxExtensions + { + public static Task EnqueueIntegrationEventAsync( + this IOutboxRepository outbox, + IntegrationEvent integrationEvent, + CancellationToken cancellationToken) + { + var message = new OutboxMessage( + integrationEvent.GetType().AssemblyQualifiedName ?? integrationEvent.GetType().Name, + JsonSerializer.Serialize(integrationEvent, integrationEvent.GetType()), + DateTime.UtcNow); + + return outbox.AddAsync(message, cancellationToken); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Application/DTOs/TodoCommentDto.cs b/Services/TodoApi/Planora.Todo.Application/DTOs/TodoCommentDto.cs deleted file mode 100644 index c9f5ca52..00000000 --- a/Services/TodoApi/Planora.Todo.Application/DTOs/TodoCommentDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Planora.Todo.Application.DTOs -{ - public sealed record TodoCommentDto( - Guid Id, - Guid TodoItemId, - Guid AuthorId, - string AuthorName, - string? AuthorAvatarUrl, - string Content, - DateTime CreatedAt, - DateTime? UpdatedAt, - bool IsOwn, - bool IsEdited, - bool IsSystemComment = false, - bool IsGenesisComment = false - ); -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommand.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommand.cs deleted file mode 100644 index 92bf4e85..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.Todo.Application.DTOs; - -namespace Planora.Todo.Application.Features.Todos.Commands.AddComment -{ - public sealed record AddCommentCommand(Guid TodoId, string Content) : ICommand>; -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandHandler.cs deleted file mode 100644 index a439b5f9..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandHandler.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.BuildingBlocks.Application.Context; -using Planora.Todo.Application.DTOs; -using Planora.Todo.Application.Services; -using Planora.Todo.Domain.Repositories; - -namespace Planora.Todo.Application.Features.Todos.Commands.AddComment -{ - public sealed class AddCommentCommandHandler : IRequestHandler> - { - private readonly ITodoRepository _todoRepository; - private readonly ITodoCommentRepository _commentRepository; - private readonly IUnitOfWork _unitOfWork; - private readonly ICurrentUserContext _currentUserContext; - private readonly IFriendshipService _friendshipService; - - public AddCommentCommandHandler( - ITodoRepository todoRepository, - ITodoCommentRepository commentRepository, - IUnitOfWork unitOfWork, - ICurrentUserContext currentUserContext, - IFriendshipService friendshipService) - { - _todoRepository = todoRepository; - _commentRepository = commentRepository; - _unitOfWork = unitOfWork; - _currentUserContext = currentUserContext; - _friendshipService = friendshipService; - } - - public async Task> Handle(AddCommentCommand request, CancellationToken cancellationToken) - { - var userId = _currentUserContext.UserId; - if (userId == Guid.Empty) - throw new UnauthorizedAccessException("User context is not available"); - - var todoItem = await _todoRepository.GetByIdWithIncludesAsync(request.TodoId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - - var isOwner = todoItem.UserId == userId; - var isSharedDirectly = todoItem.SharedWith.Any(s => s.SharedWithUserId == userId); - - // Public and shared todos still require friendship — otherwise any authenticated - // user who knows the todo ID can post comments on a stranger's public task. - var hasVisibility = todoItem.IsPublic || isSharedDirectly; - var isFriend = hasVisibility && !isOwner - ? await _friendshipService.AreFriendsAsync(userId, todoItem.UserId, cancellationToken) - : false; - - var hasAccess = isOwner || isSharedDirectly && isFriend || todoItem.IsPublic && isFriend; - - if (!hasAccess) - throw new ForbiddenException("You do not have access to this task"); - - var authorName = _currentUserContext.Name - ?? _currentUserContext.Email - ?? userId.ToString(); - - var comment = TodoItemComment.Create(todoItem.Id, userId, authorName, request.Content); - await _commentRepository.AddAsync(comment, cancellationToken); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - // Avatar URL comes from the live caller context — this is the author themselves, - // so their JWT claim is the freshest source available on the write path. - var authorAvatarUrl = string.IsNullOrEmpty(_currentUserContext.ProfilePictureUrl) - ? null - : _currentUserContext.ProfilePictureUrl; - - return Result.Success(new TodoCommentDto( - comment.Id, - comment.TodoItemId, - comment.AuthorId, - comment.AuthorName, - authorAvatarUrl, - comment.Content, - comment.CreatedAt, - comment.UpdatedAt, - IsOwn: true, - IsEdited: false, - IsSystemComment: false)); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs deleted file mode 100644 index b5ea42d8..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Planora.Todo.Application.Features.Todos.Commands.AddComment -{ - public sealed class AddCommentCommandValidator : AbstractValidator - { - public AddCommentCommandValidator() - { - RuleFor(x => x.TodoId).NotEmpty().WithMessage("TodoId is required"); - RuleFor(x => x.Content) - .NotEmpty().WithMessage("Content cannot be empty") - .MaximumLength(2000).WithMessage("Content cannot exceed 2000 characters"); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommand.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommand.cs deleted file mode 100644 index 177258f3..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.Todo.Application.DTOs; - -namespace Planora.Todo.Application.Features.Todos.Commands.AddGenesisComment -{ - public sealed record AddGenesisCommentCommand(Guid TodoId, string Content) : ICommand>; -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs deleted file mode 100644 index 7fe1cf1d..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.BuildingBlocks.Application.Context; -using Planora.Todo.Application.DTOs; -using Planora.Todo.Domain.Entities; -using Planora.Todo.Domain.Repositories; - -namespace Planora.Todo.Application.Features.Todos.Commands.AddGenesisComment -{ - public sealed class AddGenesisCommentCommandHandler : IRequestHandler> - { - private readonly ITodoRepository _todoRepository; - private readonly ITodoCommentRepository _commentRepository; - private readonly IUnitOfWork _unitOfWork; - private readonly ICurrentUserContext _currentUserContext; - - public AddGenesisCommentCommandHandler( - ITodoRepository todoRepository, - ITodoCommentRepository commentRepository, - IUnitOfWork unitOfWork, - ICurrentUserContext currentUserContext) - { - _todoRepository = todoRepository; - _commentRepository = commentRepository; - _unitOfWork = unitOfWork; - _currentUserContext = currentUserContext; - } - - public async Task> Handle(AddGenesisCommentCommand request, CancellationToken cancellationToken) - { - var userId = _currentUserContext.UserId; - if (userId == Guid.Empty) - throw new UnauthorizedAccessException("User context is not available"); - - var todoItem = await _todoRepository.GetByIdWithIncludesAsync(request.TodoId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - - if (todoItem.UserId != userId) - throw new ForbiddenException("Only the task owner can add a description"); - - var existing = await _commentRepository.GetGenesisCommentAsync(request.TodoId, cancellationToken); - if (existing is not null) - return Result.Failure(new Error("GENESIS_ALREADY_EXISTS", "A description already exists for this task")); - - var authorName = _currentUserContext.Name - ?? _currentUserContext.Email - ?? userId.ToString(); - - var comment = TodoItemComment.CreateGenesis(todoItem.Id, request.Content, authorName); - await _commentRepository.AddAsync(comment, cancellationToken); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - var authorAvatarUrl = string.IsNullOrEmpty(_currentUserContext.ProfilePictureUrl) - ? null - : _currentUserContext.ProfilePictureUrl; - - return Result.Success(new TodoCommentDto( - comment.Id, - comment.TodoItemId, - comment.AuthorId, - comment.AuthorName, - authorAvatarUrl, - comment.Content, - comment.CreatedAt, - comment.UpdatedAt, - IsOwn: true, - IsEdited: false, - IsSystemComment: true, - IsGenesisComment: true)); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateTodo/CreateTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateTodo/CreateTodoCommandHandler.cs index 2cd1cd7d..286576c2 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateTodo/CreateTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateTodo/CreateTodoCommandHandler.cs @@ -1,7 +1,10 @@ using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; using Planora.BuildingBlocks.Domain; using Planora.BuildingBlocks.Domain.Exceptions; using Planora.BuildingBlocks.Application.Services; +using Planora.Todo.Application.Common; using Planora.Todo.Application.DTOs; using Planora.Todo.Application.Interfaces; using Planora.Todo.Application.Services; @@ -20,7 +23,7 @@ public sealed class CreateTodoCommandHandler : IRequestHandler> Handle( var authorName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); - var systemComment = TodoItemComment.CreateSystem(todoItem.Id, $"{authorName} created the task"); - await _commentRepository.AddAsync(systemComment, cancellationToken); - - if (!string.IsNullOrWhiteSpace(request.Description)) - { - var genesisComment = TodoItemComment.CreateGenesis(todoItem.Id, request.Description, authorName); - await _commentRepository.AddAsync(genesisComment, cancellationToken); - } + // The task's activity timeline ("ветка") lives in the Collaboration service. Publish a + // creation fact via the outbox; Collaboration materialises the "created the task" system + // comment and the genesis comment (the description) from it. INV-COMM-3. + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskCreatedIntegrationEvent(todoItem.Id, userId, authorName, request.Description), + cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommand.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommand.cs deleted file mode 100644 index 09fb9232..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Planora.BuildingBlocks.Domain; - -namespace Planora.Todo.Application.Features.Todos.Commands.DeleteComment -{ - public sealed record DeleteCommentCommand(Guid TodoId, Guid CommentId) : ICommand; -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs deleted file mode 100644 index 5ebced69..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.BuildingBlocks.Application.Context; -using Planora.Todo.Domain.Repositories; - -namespace Planora.Todo.Application.Features.Todos.Commands.DeleteComment -{ - public sealed class DeleteCommentCommandHandler : IRequestHandler - { - private readonly ITodoCommentRepository _commentRepository; - private readonly ITodoRepository _todoRepository; - private readonly IUnitOfWork _unitOfWork; - private readonly ICurrentUserContext _currentUserContext; - - public DeleteCommentCommandHandler( - ITodoCommentRepository commentRepository, - ITodoRepository todoRepository, - IUnitOfWork unitOfWork, - ICurrentUserContext currentUserContext) - { - _commentRepository = commentRepository; - _todoRepository = todoRepository; - _unitOfWork = unitOfWork; - _currentUserContext = currentUserContext; - } - - public async Task Handle(DeleteCommentCommand request, CancellationToken cancellationToken) - { - var userId = _currentUserContext.UserId; - - var comment = await _commentRepository.GetByIdAsync(request.CommentId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItemComment", request.CommentId); - - if (comment.TodoItemId != request.TodoId) - throw new EntityNotFoundException("TodoItemComment", request.CommentId); - - var todoItem = await _todoRepository.GetByIdWithIncludesAsync(request.TodoId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - - if (comment.IsSystemComment && !comment.IsGenesisComment) - throw new ForbiddenException("System event comments cannot be deleted"); - - var isAuthor = comment.AuthorId == userId; - var isTodoOwner = todoItem.UserId == userId; - - if (comment.IsGenesisComment && !isTodoOwner) - throw new ForbiddenException("Only the task owner can delete the description"); - - if (!isAuthor && !isTodoOwner) - throw new ForbiddenException("Only the comment author or task owner can delete this comment"); - - comment.MarkAsDeleted(userId); - _commentRepository.Update(comment); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - return Result.Success(); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs index 6f276b73..97543589 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs @@ -1,6 +1,10 @@ using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; using Planora.BuildingBlocks.Domain; using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Todo.Application.Common; using Planora.Todo.Domain.Repositories; using MediatR; @@ -9,20 +13,20 @@ namespace Planora.Todo.Application.Features.Todos.Commands.DeleteTodo public sealed class DeleteTodoCommandHandler : IRequestHandler { private readonly IRepository _repository; - private readonly ITodoCommentRepository _commentRepository; + private readonly IOutboxRepository _outboxRepository; private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly ICurrentUserContext _currentUserContext; public DeleteTodoCommandHandler( IRepository repository, - ITodoCommentRepository commentRepository, + IOutboxRepository outboxRepository, IUnitOfWork unitOfWork, ILogger logger, ICurrentUserContext currentUserContext) { _repository = repository; - _commentRepository = commentRepository; + _outboxRepository = outboxRepository; _unitOfWork = unitOfWork; _logger = logger; _currentUserContext = currentUserContext; @@ -40,7 +44,13 @@ public async Task Handle(DeleteTodoCommand request, CancellationToken ca todoItem.MarkAsDeleted(userId); _repository.Update(todoItem); - await _commentRepository.SoftDeleteByTodoIdAsync(todoItem.Id, userId, cancellationToken); + + // The task's comment timeline ("ветка") lives in the Collaboration service. Publish a + // deletion fact via the outbox; Collaboration cascade-soft-deletes the comments. INV-COMM-3. + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskDeletedIntegrationEvent(todoItem.Id, userId), + cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); _logger.LogInformation("Todo item deleted: {TodoId} by user {UserId}", request.TodoId, userId); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs index e93c3515..d0c99de3 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs @@ -1,6 +1,9 @@ using Planora.BuildingBlocks.Domain; using Planora.BuildingBlocks.Domain.Exceptions; using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; +using Planora.Todo.Application.Common; using Planora.Todo.Application.DTOs; using Planora.Todo.Application.Services; using Planora.Todo.Domain.Entities; @@ -16,7 +19,7 @@ public sealed class JoinTodoCommandHandler : IRequestHandler _logger; public JoinTodoCommandHandler( @@ -25,7 +28,7 @@ public JoinTodoCommandHandler( IMapper mapper, ICurrentUserContext currentUserContext, IFriendshipService friendshipService, - ITodoCommentRepository commentRepository, + IOutboxRepository outboxRepository, ILogger logger) { _repository = repository; @@ -33,7 +36,7 @@ public JoinTodoCommandHandler( _mapper = mapper; _currentUserContext = currentUserContext; _friendshipService = friendshipService; - _commentRepository = commentRepository; + _outboxRepository = outboxRepository; _logger = logger; } @@ -86,8 +89,9 @@ public async Task> Handle(JoinTodoCommand request, Cancellat todoItem.AddWorker(userId); var userName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); - var systemComment = TodoItemComment.CreateSystem(todoItem.Id, $"{userName} started working on the task"); - await _commentRepository.AddAsync(systemComment, cancellationToken); + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskActivityIntegrationEvent(todoItem.Id, userId, userName, TaskActivityType.StartedWorking), + cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs index 96915d4c..99196b89 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs @@ -1,6 +1,9 @@ using Planora.BuildingBlocks.Domain; using Planora.BuildingBlocks.Domain.Exceptions; using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; +using Planora.Todo.Application.Common; using Planora.Todo.Domain.Entities; using Planora.Todo.Domain.Repositories; using Microsoft.Extensions.Logging; @@ -12,20 +15,20 @@ public sealed class LeaveTodoCommandHandler : IRequestHandler _logger; public LeaveTodoCommandHandler( ITodoRepository repository, IUnitOfWork unitOfWork, ICurrentUserContext currentUserContext, - ITodoCommentRepository commentRepository, + IOutboxRepository outboxRepository, ILogger logger) { _repository = repository; _unitOfWork = unitOfWork; _currentUserContext = currentUserContext; - _commentRepository = commentRepository; + _outboxRepository = outboxRepository; _logger = logger; } @@ -44,8 +47,9 @@ public async Task Handle(LeaveTodoCommand request, CancellationToken can todoItem.RemoveWorker(userId); var userName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); - var systemComment = TodoItemComment.CreateSystem(todoItem.Id, $"{userName} left the task"); - await _commentRepository.AddAsync(systemComment, cancellationToken); + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskActivityIntegrationEvent(todoItem.Id, userId, userName, TaskActivityType.Left), + cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommand.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommand.cs deleted file mode 100644 index 2c4a5121..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.Todo.Application.DTOs; - -namespace Planora.Todo.Application.Features.Todos.Commands.UpdateComment -{ - public sealed record UpdateCommentCommand(Guid TodoId, Guid CommentId, string Content) : ICommand>; -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs deleted file mode 100644 index 679a93a3..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.BuildingBlocks.Application.Context; -using Planora.Todo.Application.DTOs; -using Planora.Todo.Application.Services; -using Planora.Todo.Domain.Repositories; - -namespace Planora.Todo.Application.Features.Todos.Commands.UpdateComment -{ - public sealed class UpdateCommentCommandHandler : IRequestHandler> - { - private readonly ITodoCommentRepository _commentRepository; - private readonly ITodoRepository _todoRepository; - private readonly IUnitOfWork _unitOfWork; - private readonly ICurrentUserContext _currentUserContext; - private readonly IUserService _userService; - - public UpdateCommentCommandHandler( - ITodoCommentRepository commentRepository, - ITodoRepository todoRepository, - IUnitOfWork unitOfWork, - ICurrentUserContext currentUserContext, - IUserService userService) - { - _commentRepository = commentRepository; - _todoRepository = todoRepository; - _unitOfWork = unitOfWork; - _currentUserContext = currentUserContext; - _userService = userService; - } - - public async Task> Handle(UpdateCommentCommand request, CancellationToken cancellationToken) - { - var userId = _currentUserContext.UserId; - - var comment = await _commentRepository.GetByIdAsync(request.CommentId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItemComment", request.CommentId); - - if (comment.TodoItemId != request.TodoId) - throw new EntityNotFoundException("TodoItemComment", request.CommentId); - - if (comment.IsGenesisComment) - { - var todoItem = await _todoRepository.GetByIdWithIncludesAsync(request.TodoId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - - if (todoItem.UserId != userId) - throw new ForbiddenException("Only the task owner can edit the description"); - - comment.UpdateGenesisContent(request.Content, userId); - } - else - { - comment.UpdateContent(request.Content, userId); - } - - _commentRepository.Update(comment); - await _unitOfWork.SaveChangesAsync(cancellationToken); - - // Genesis comments are authored by the task owner (AuthorId is Empty); for them we - // resolve the avatar of the todo's owner, not the current editor. - var resolvedAuthorId = comment.IsGenesisComment ? userId : comment.AuthorId; - string? authorAvatarUrl = null; - if (resolvedAuthorId != Guid.Empty) - { - var avatars = await _userService.GetUserAvatarsAsync(new[] { resolvedAuthorId }, cancellationToken); - avatars.TryGetValue(resolvedAuthorId, out authorAvatarUrl); - } - - return Result.Success(new TodoCommentDto( - comment.Id, - comment.TodoItemId, - comment.AuthorId, - comment.AuthorName, - authorAvatarUrl, - comment.Content, - comment.CreatedAt, - comment.UpdatedAt, - IsOwn: !comment.IsGenesisComment, - IsEdited: comment.IsEdited, - IsSystemComment: comment.IsSystemComment, - IsGenesisComment: comment.IsGenesisComment)); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs deleted file mode 100644 index dd107ff8..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Planora.Todo.Application.Features.Todos.Commands.UpdateComment -{ - public sealed class UpdateCommentCommandValidator : AbstractValidator - { - public UpdateCommentCommandValidator() - { - RuleFor(x => x.CommentId).NotEmpty().WithMessage("CommentId is required"); - RuleFor(x => x.Content) - .NotEmpty().WithMessage("Content cannot be empty") - .MaximumLength(2000).WithMessage("Content cannot exceed 2000 characters"); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs index 3e9da134..9cfd46be 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs @@ -1,7 +1,10 @@ using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; using Planora.BuildingBlocks.Domain; using Planora.BuildingBlocks.Domain.Exceptions; using Planora.BuildingBlocks.Application.Services; +using Planora.Todo.Application.Common; using Planora.Todo.Application.DTOs; using Planora.Todo.Application.Interfaces; using Planora.Todo.Application.Services; @@ -24,7 +27,7 @@ public sealed class UpdateTodoCommandHandler : IRequestHandler>>; -} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs deleted file mode 100644 index 4dae17d4..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Planora.BuildingBlocks.Application.Context; -using Planora.BuildingBlocks.Application.CQRS; -using Planora.BuildingBlocks.Application.Pagination; -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.Todo.Application.DTOs; -using Planora.Todo.Application.Services; -using Planora.Todo.Domain.Repositories; - -namespace Planora.Todo.Application.Features.Todos.Queries.GetComments -{ - public sealed class GetCommentsQueryHandler - : IQueryHandler>> - { - private readonly ITodoRepository _todoRepository; - private readonly ITodoCommentRepository _commentRepository; - private readonly ICurrentUserContext _currentUserContext; - private readonly IFriendshipService _friendshipService; - private readonly IUserService _userService; - - public GetCommentsQueryHandler( - ITodoRepository todoRepository, - ITodoCommentRepository commentRepository, - ICurrentUserContext currentUserContext, - IFriendshipService friendshipService, - IUserService userService) - { - _todoRepository = todoRepository; - _commentRepository = commentRepository; - _currentUserContext = currentUserContext; - _friendshipService = friendshipService; - _userService = userService; - } - - public async Task>> Handle( - GetCommentsQuery request, CancellationToken cancellationToken) - { - var userId = _currentUserContext.UserId; - if (userId == Guid.Empty) - return Result>.Failure( - new Error("AUTH_REQUIRED", "User context is not available")); - - var todoItem = await _todoRepository.GetByIdWithIncludesAsync(request.TodoId, cancellationToken) - ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - - var isOwner = todoItem.UserId == userId; - var isSharedDirectly = todoItem.SharedWith.Any(s => s.SharedWithUserId == userId); - var hasVisibility = todoItem.IsPublic || isSharedDirectly; - var isFriend = hasVisibility && !isOwner - ? await _friendshipService.AreFriendsAsync(userId, todoItem.UserId, cancellationToken) - : false; - var hasAccess = isOwner || isSharedDirectly && isFriend || todoItem.IsPublic && isFriend; - - if (!hasAccess) - throw new ForbiddenException("You do not have access to this task"); - - var (items, totalCount) = await _commentRepository.GetPagedByTodoIdAsync( - request.TodoId, request.PageNumber, request.PageSize, cancellationToken); - - // Always batch-fetch avatars via Auth gRPC. The snapshot column was removed in - // migration RemoveCommentAvatarSnapshot — single source of truth is the live - // user profile, cached by CachingUserService (60 s TTL) so a paged read does - // not multiply Auth load. - // - // Regular comments: AuthorId is the actual commenter. - // Genesis comment: AuthorId = Guid.Empty by design; the real author is the - // task owner (todoItem.UserId). - var authorIds = new HashSet(); - foreach (var c in items) - { - if (c.IsGenesisComment) - { - authorIds.Add(todoItem.UserId); - } - else if (!c.IsSystemComment && c.AuthorId != Guid.Empty) - { - authorIds.Add(c.AuthorId); - } - } - - IReadOnlyDictionary avatars = authorIds.Count > 0 - ? await _userService.GetUserAvatarsAsync(authorIds, cancellationToken) - : new Dictionary(); - - var dtos = items.Select(c => - { - string? avatarUrl; - if (c.IsGenesisComment) - { - avatars.TryGetValue(todoItem.UserId, out avatarUrl); - } - else - { - avatars.TryGetValue(c.AuthorId, out avatarUrl); - } - - return new TodoCommentDto( - c.Id, - c.TodoItemId, - c.AuthorId, - c.AuthorName, - avatarUrl, - c.Content, - c.CreatedAt, - c.UpdatedAt, - IsOwn: !c.IsSystemComment && c.AuthorId == userId, - IsEdited: c.IsEdited, - IsSystemComment: c.IsSystemComment, - IsGenesisComment: c.IsGenesisComment); - }).ToList(); - - return Result>.Success( - new PagedResult(dtos, request.PageNumber, request.PageSize, totalCount)); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Application/Services/IUserService.cs b/Services/TodoApi/Planora.Todo.Application/Services/IUserService.cs deleted file mode 100644 index 43d0ff8a..00000000 --- a/Services/TodoApi/Planora.Todo.Application/Services/IUserService.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Planora.Todo.Application.Services -{ - /// - /// Provides current user profile data from the Auth service. - /// Used to enrich comment author avatars that were stored before profile pictures were set. - /// - public interface IUserService - { - /// - /// Returns the current profile picture URLs for the given user IDs. - /// Users without a profile picture are omitted from the result. - /// Failures are swallowed — the caller should treat missing entries as "no avatar". - /// - Task> GetUserAvatarsAsync( - IEnumerable userIds, - CancellationToken cancellationToken = default); - } -} diff --git a/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItemComment.cs b/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItemComment.cs deleted file mode 100644 index f6e33c13..00000000 --- a/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItemComment.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.Todo.Domain.Events; - -namespace Planora.Todo.Domain.Entities -{ - public sealed class TodoItemComment : BaseEntity, IAggregateRoot - { - public Guid TodoItemId { get; private set; } - public Guid AuthorId { get; private set; } - public string AuthorName { get; private set; } = string.Empty; - public string Content { get; private set; } = string.Empty; - public bool IsSystemComment { get; private set; } - public bool IsGenesisComment { get; private set; } - - public bool IsEdited => - (!IsSystemComment || IsGenesisComment) && UpdatedAt.HasValue && UpdatedAt.Value > CreatedAt.AddSeconds(5); - - private TodoItemComment() { } - - public static TodoItemComment Create( - Guid todoItemId, - Guid authorId, - string authorName, - string content) - { - if (todoItemId == Guid.Empty) - throw new InvalidValueObjectException(nameof(TodoItemComment), "TodoItemId cannot be empty"); - if (authorId == Guid.Empty) - throw new InvalidValueObjectException(nameof(TodoItemComment), "AuthorId cannot be empty"); - if (string.IsNullOrWhiteSpace(authorName)) - throw new InvalidValueObjectException(nameof(TodoItemComment), "AuthorName cannot be empty"); - if (string.IsNullOrWhiteSpace(content)) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot be empty"); - if (content.Length > 2000) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot exceed 2000 characters"); - - var comment = new TodoItemComment - { - TodoItemId = todoItemId, - AuthorId = authorId, - AuthorName = authorName.Trim(), - Content = content.Trim(), - IsSystemComment = false, - }; - comment.AddDomainEvent(new TodoCommentAddedDomainEvent(comment.Id, todoItemId, authorId)); - - return comment; - } - - public static TodoItemComment CreateSystem(Guid todoItemId, string content) - { - if (todoItemId == Guid.Empty) - throw new InvalidValueObjectException(nameof(TodoItemComment), "TodoItemId cannot be empty"); - if (string.IsNullOrWhiteSpace(content)) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot be empty"); - - return new TodoItemComment - { - TodoItemId = todoItemId, - AuthorId = Guid.Empty, - AuthorName = string.Empty, - Content = content.Trim(), - IsSystemComment = true, - IsGenesisComment = false, - }; - } - - public static TodoItemComment CreateGenesis( - Guid todoItemId, - string content, - string authorName) - { - if (todoItemId == Guid.Empty) - throw new InvalidValueObjectException(nameof(TodoItemComment), "TodoItemId cannot be empty"); - if (string.IsNullOrWhiteSpace(content)) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot be empty"); - if (content.Length > 5000) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Description cannot exceed 5000 characters"); - - return new TodoItemComment - { - TodoItemId = todoItemId, - AuthorId = Guid.Empty, - AuthorName = string.IsNullOrWhiteSpace(authorName) ? string.Empty : authorName.Trim(), - Content = content.Trim(), - IsSystemComment = true, - IsGenesisComment = true, - }; - } - - public void UpdateGenesisContent(string content, Guid ownerUserId) - { - if (!IsGenesisComment) - throw new ForbiddenException("Only the genesis comment can be updated via this method"); - if (string.IsNullOrWhiteSpace(content)) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot be empty"); - if (content.Length > 5000) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Description cannot exceed 5000 characters"); - - Content = content.Trim(); - MarkAsModified(ownerUserId); - } - - public void UpdateContent(string content, Guid editorUserId) - { - if (editorUserId != AuthorId) - throw new ForbiddenException("Only the author can edit this comment"); - if (string.IsNullOrWhiteSpace(content)) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot be empty"); - if (content.Length > 2000) - throw new InvalidValueObjectException(nameof(TodoItemComment), "Content cannot exceed 2000 characters"); - - Content = content.Trim(); - MarkAsModified(editorUserId); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Domain/Events/TodoCommentAddedDomainEvent.cs b/Services/TodoApi/Planora.Todo.Domain/Events/TodoCommentAddedDomainEvent.cs deleted file mode 100644 index dc162bd0..00000000 --- a/Services/TodoApi/Planora.Todo.Domain/Events/TodoCommentAddedDomainEvent.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Planora.BuildingBlocks.Domain; - -namespace Planora.Todo.Domain.Events -{ - public sealed record TodoCommentAddedDomainEvent( - Guid CommentId, - Guid TodoItemId, - Guid AuthorId) : DomainEvent; -} diff --git a/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoCommentRepository.cs b/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoCommentRepository.cs deleted file mode 100644 index 025e20ac..00000000 --- a/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoCommentRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Planora.BuildingBlocks.Domain.Interfaces; -using Planora.Todo.Domain.Entities; - -namespace Planora.Todo.Domain.Repositories -{ - public interface ITodoCommentRepository : IRepository - { - Task<(IReadOnlyList Items, int TotalCount)> GetPagedByTodoIdAsync( - Guid todoItemId, int pageNumber, int pageSize, CancellationToken ct = default); - Task SoftDeleteByTodoIdAsync(Guid todoItemId, Guid deletedBy, CancellationToken ct = default); - Task GetGenesisCommentAsync(Guid todoItemId, CancellationToken ct = default); - } -} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/DependencyInjection.cs b/Services/TodoApi/Planora.Todo.Infrastructure/DependencyInjection.cs index 1fb7cf73..a5f2605d 100644 --- a/Services/TodoApi/Planora.Todo.Infrastructure/DependencyInjection.cs +++ b/Services/TodoApi/Planora.Todo.Infrastructure/DependencyInjection.cs @@ -32,17 +32,25 @@ public static IServiceCollection AddTodoInfrastructure( }) .EnableSensitiveDataLogging(false)); + // Register TodoDbContext as DbContext for the OutboxProcessor. + services.AddScoped(sp => sp.GetRequiredService()); + // Repositories & Unit of Work services.AddScoped(); services.AddScoped(); services.AddScoped, TodoRepository>(); services.AddScoped(); - services.AddScoped(); - + + // Outbox — publishes task lifecycle integration events that drive the Collaboration + // service's comment timeline ("ветки"). INV-COMM-3. + services.AddScoped(); + services.AddHostedService(); + // Services services.AddHttpContextAccessor(); services.AddScoped(); - + // gRPC client for Auth API friendship checks services.AddSingleton(); var authGrpcUrl = configuration["GrpcServices:AuthApi"] @@ -53,16 +61,6 @@ public static IServiceCollection AddTodoInfrastructure( .AddInterceptor(); services.AddScoped(); - // Avatar enrichment: gRPC call is wrapped by an in-memory cache so paged - // comment reads do not hammer Auth for the same authors over and over. - // CachingUserService → UserGrpcService → AuthService.gRPC. - services.AddMemoryCache(options => { options.SizeLimit = 10_000; }); - services.AddScoped(); - services.AddScoped(sp => new Services.CachingUserService( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>())); - // gRPC client for Category API (port 5282 local / env-configurable) var categoryGrpcUrl = configuration["GrpcServices:CategoryApi"] ?? "http://localhost:5282"; services.AddGrpcClient(o => diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs index 4a7b4529..8c52192b 100644 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs @@ -128,24 +128,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TodoItems", "todo"); }); - modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemComment", b => + modelBuilder.Entity("Planora.BuildingBlocks.Application.Outbox.OutboxMessage", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("AuthorId") - .HasColumnType("uuid"); - - b.Property("AuthorName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - b.Property("Content") .IsRequired() - .HasMaxLength(5000) - .HasColumnType("character varying(5000)"); + .HasColumnType("text"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -159,23 +150,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeletedBy") .HasColumnType("uuid"); + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); + .HasColumnType("boolean"); - b.Property("IsGenesisComment") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); + b.Property("NextRetryUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone"); - b.Property("IsSystemComment") + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); + .HasColumnType("integer") + .HasDefaultValue(0); - b.Property("TodoItemId") - .HasColumnType("uuid"); + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -185,9 +188,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("TodoItemId", "CreatedAt"); + b.HasIndex("ProcessedOnUtc"); + + b.HasIndex("Status", "OccurredOnUtc"); - b.ToTable("todo_item_comments", "todo"); + b.ToTable("OutboxMessages", "todo"); }); modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => @@ -287,15 +292,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tags"); }); - modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemComment", b => - { - b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) - .WithMany() - .HasForeignKey("TodoItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => { b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs new file mode 100644 index 00000000..2bfa37ff --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs @@ -0,0 +1,40 @@ +using Planora.BuildingBlocks.Application.Outbox; + +namespace Planora.Todo.Infrastructure.Persistence.Configurations +{ + public sealed class OutboxMessageConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OutboxMessages"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Type) + .IsRequired() + .HasMaxLength(255); + + builder.Property(x => x.Content) + .IsRequired(); + + builder.Property(x => x.OccurredOnUtc) + .IsRequired(); + + builder.Property(x => x.ProcessedOnUtc); + + builder.Property(x => x.Status) + .IsRequired() + .HasConversion(); + + builder.Property(x => x.Error) + .HasMaxLength(2000); + + builder.Property(x => x.RetryCount) + .IsRequired() + .HasDefaultValue(0); + + builder.HasIndex(x => new { x.Status, x.OccurredOnUtc }); + builder.HasIndex(x => x.ProcessedOnUtc); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs deleted file mode 100644 index c3126b2b..00000000 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Planora.Todo.Infrastructure.Persistence.Configurations -{ - public sealed class TodoItemCommentConfiguration : IEntityTypeConfiguration - { - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("todo_item_comments"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.TodoItemId).IsRequired(); - builder.Property(x => x.AuthorId).IsRequired(); - - builder.Property(x => x.AuthorName) - .IsRequired() - .HasMaxLength(200); - - builder.Property(x => x.Content) - .IsRequired() - .HasMaxLength(5000); - - builder.Property(x => x.CreatedAt).IsRequired(); - builder.Property(x => x.UpdatedAt).IsRequired(false); - builder.Property(x => x.DeletedAt).IsRequired(false); - builder.Property(x => x.IsDeleted).HasDefaultValue(false); - - builder.Property(x => x.IsSystemComment) - .IsRequired() - .HasDefaultValue(false); - - builder.Property(x => x.IsGenesisComment) - .IsRequired() - .HasDefaultValue(false); - - builder.HasIndex(x => new { x.TodoItemId, x.CreatedAt }); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.TodoItemId) - .OnDelete(DeleteBehavior.Cascade); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/OutboxRepository.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/OutboxRepository.cs new file mode 100644 index 00000000..aed6f884 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/OutboxRepository.cs @@ -0,0 +1,46 @@ +using Planora.BuildingBlocks.Application.Outbox; + +namespace Planora.Todo.Infrastructure.Persistence.Repositories +{ + public sealed class OutboxRepository : IOutboxRepository + { + private readonly TodoDbContext _context; + + public OutboxRepository(TodoDbContext context) + { + _context = context; + } + + public async Task AddAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + await _context.OutboxMessages.AddAsync(message, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task> GetPendingMessagesAsync(int batchSize, CancellationToken cancellationToken = default) + { + return await _context.OutboxMessages + .Where(m => m.Status == OutboxMessageStatus.Pending || + (m.Status == OutboxMessageStatus.Failed && m.NextRetryUtc <= System.DateTime.UtcNow)) + .OrderBy(m => m.OccurredOnUtc) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task UpdateAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + _context.OutboxMessages.Update(message); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteProcessedMessagesAsync(DateTime olderThan, CancellationToken cancellationToken = default) + { + var messagesToDelete = await _context.OutboxMessages + .Where(m => m.Status == OutboxMessageStatus.Processed && m.ProcessedOnUtc < olderThan) + .ToListAsync(cancellationToken); + + _context.OutboxMessages.RemoveRange(messagesToDelete); + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoCommentRepository.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoCommentRepository.cs deleted file mode 100644 index ea9cc382..00000000 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoCommentRepository.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Planora.BuildingBlocks.Application.Pagination; -using Planora.BuildingBlocks.Infrastructure.Persistence; -using Planora.Todo.Domain.Repositories; - -namespace Planora.Todo.Infrastructure.Persistence.Repositories -{ - public sealed class TodoCommentRepository - : BaseRepository, ITodoCommentRepository - { - public TodoCommentRepository(TodoDbContext context) : base(context) { } - - public override async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - return await DbSet - .AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, cancellationToken); - } - - public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedByTodoIdAsync( - Guid todoItemId, int pageNumber, int pageSize, CancellationToken ct = default) - { - var (safePageNumber, safePageSize) = PaginationParameters.Normalize(pageNumber, pageSize); - - var query = DbSet - .AsNoTracking() - .Where(c => c.TodoItemId == todoItemId && !c.IsDeleted); - - var totalCount = await query.CountAsync(ct); - - var items = await query - .OrderBy(c => c.CreatedAt) - .Skip((safePageNumber - 1) * safePageSize) - .Take(safePageSize) - .ToListAsync(ct); - - return (items, totalCount); - } - - public async Task GetGenesisCommentAsync(Guid todoItemId, CancellationToken ct = default) - { - return await DbSet - .AsNoTracking() - .FirstOrDefaultAsync(c => c.TodoItemId == todoItemId && c.IsGenesisComment && !c.IsDeleted, ct); - } - - public async Task SoftDeleteByTodoIdAsync(Guid todoItemId, Guid deletedBy, CancellationToken ct = default) - { - // Load-then-update instead of ExecuteUpdateAsync: works with all EF Core - // providers including InMemory (used in integration tests), which does not - // support ExecuteUpdateAsync/ExecuteDeleteAsync bulk operations. - // Comment counts per todo are bounded, so the extra round-trip is negligible. - // Changes are flushed by the caller's UnitOfWork.SaveChangesAsync(). - var comments = await DbSet - .Where(c => c.TodoItemId == todoItemId && !c.IsDeleted) - .ToListAsync(ct); - - foreach (var comment in comments) - comment.MarkAsDeleted(deletedBy); - } - } -} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/TodoDbContext.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/TodoDbContext.cs index 5d11eafc..84c8cbd8 100644 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/TodoDbContext.cs +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/TodoDbContext.cs @@ -7,7 +7,8 @@ public TodoDbContext(DbContextOptions options) : base(options) { public DbSet TodoItems => Set(); public DbSet TodoItemShares => Set(); public DbSet UserTodoViewPreferences => Set(); - public DbSet TodoItemComments => Set(); + public DbSet OutboxMessages => + Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs deleted file mode 100644 index 83a63240..00000000 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Planora.Todo.Application.Services; - -namespace Planora.Todo.Infrastructure.Services -{ - /// - /// In-memory cache wrapper around . - /// Comment listings page through the same authors repeatedly; without caching each - /// page hit Auth gRPC. A 60 s TTL bounds staleness when a user changes their avatar - /// while keeping the cost of comment reads low. - /// - public sealed class CachingUserService : IUserService - { - private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(60); - private const string KeyPrefix = "todo:user-avatar:"; - - private readonly IUserService _inner; - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - - public CachingUserService( - IUserService inner, - IMemoryCache cache, - ILogger logger) - { - _inner = inner; - _cache = cache; - _logger = logger; - } - - public async Task> GetUserAvatarsAsync( - IEnumerable userIds, - CancellationToken cancellationToken = default) - { - var ids = userIds.Distinct().ToList(); - if (ids.Count == 0) - { - return new Dictionary(); - } - - var result = new Dictionary(ids.Count); - var missing = new List(); - - foreach (var id in ids) - { - if (_cache.TryGetValue(Key(id), out CacheEntry? entry) && entry is not null) - { - if (entry.Url is not null) - { - result[id] = entry.Url; - } - } - else - { - missing.Add(id); - } - } - - if (missing.Count == 0) - { - return result; - } - - var fresh = await _inner.GetUserAvatarsAsync(missing, cancellationToken); - - foreach (var id in missing) - { - fresh.TryGetValue(id, out var url); - _cache.Set(Key(id), new CacheEntry(url), new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = Ttl, - Size = 1, - }); - if (url is not null) - { - result[id] = url; - } - } - - if (missing.Count > 0) - { - _logger.LogDebug( - "Avatar cache miss: {MissCount}/{TotalCount} fetched from Auth gRPC", - missing.Count, ids.Count); - } - - return result; - } - - private static string Key(Guid id) => KeyPrefix + id.ToString("N"); - - private sealed record CacheEntry(string? Url); - } -} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs deleted file mode 100644 index ceab64b0..00000000 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Grpc.Core; -using Planora.GrpcContracts; -using Planora.Todo.Application.Services; -using Microsoft.Extensions.Logging; - -namespace Planora.Todo.Infrastructure.Services -{ - public sealed class UserGrpcService : IUserService - { - private readonly AuthService.AuthServiceClient _client; - private readonly ILogger _logger; - - public UserGrpcService( - AuthService.AuthServiceClient client, - ILogger logger) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task> GetUserAvatarsAsync( - IEnumerable userIds, - CancellationToken cancellationToken = default) - { - var ids = userIds.Distinct().ToList(); - if (ids.Count == 0) - return new Dictionary(); - - try - { - var request = new GetUserAvatarsBatchRequest(); - request.UserIds.AddRange(ids.Select(id => id.ToString())); - - var response = await _client.GetUserAvatarsBatchAsync( - request, - cancellationToken: cancellationToken); - - var result = new Dictionary(); - foreach (var kvp in response.AvatarUrls) - { - if (Guid.TryParse(kvp.Key, out var guid) && !string.IsNullOrEmpty(kvp.Value)) - result[guid] = kvp.Value; - } - - return result; - } - catch (RpcException ex) - { - _logger.LogWarning( - ex, - "Auth gRPC unavailable while fetching avatars for {Count} users: Status={Status}", - ids.Count, - ex.StatusCode); - // Non-fatal — comment thread still loads, just without live avatars - return new Dictionary(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to fetch user avatars from Auth gRPC"); - return new Dictionary(); - } - } - } -} diff --git a/tests/Planora.UnitTests/Architecture/ArchitectureTests.cs b/tests/Planora.UnitTests/Architecture/ArchitectureTests.cs index 941374a5..93794e6b 100644 --- a/tests/Planora.UnitTests/Architecture/ArchitectureTests.cs +++ b/tests/Planora.UnitTests/Architecture/ArchitectureTests.cs @@ -19,6 +19,7 @@ private static readonly (string Name, Assembly Assembly)[] DomainAssemblies = ("Planora.Todo.Domain", typeof(global::Planora.Todo.Domain.Entities.TodoItem).Assembly), ("Planora.Category.Domain", typeof(global::Planora.Category.Domain.Entities.Category).Assembly), ("Planora.Messaging.Domain", typeof(global::Planora.Messaging.Domain.Entities.Message).Assembly), + ("Planora.Collaboration.Domain", typeof(global::Planora.Collaboration.Domain.Entities.Comment).Assembly), }; private static readonly (string Name, Assembly Assembly)[] ApplicationAssemblies = @@ -29,6 +30,7 @@ private static readonly (string Name, Assembly Assembly)[] ApplicationAssemblies ("Planora.Category.Application", typeof(global::Planora.Category.Application.Features.IntegrationEvents.UserDeletedEventConsumer).Assembly), ("Planora.Messaging.Application", typeof(global::Planora.Messaging.Application.Features.Messages.Mappings.MessageMappingProfile).Assembly), ("Planora.Realtime.Application", typeof(global::Planora.Realtime.Application.Handlers.NotificationEventHandler).Assembly), + ("Planora.Collaboration.Application", typeof(global::Planora.Collaboration.Application.DTOs.CommentDto).Assembly), }; // Namespaces that belong to the infrastructure layer or to infrastructure @@ -57,11 +59,13 @@ private static readonly (string Name, Assembly Assembly)[] ApplicationAssemblies "Planora.Category.Infrastructure", "Planora.Messaging.Infrastructure", "Planora.Realtime.Infrastructure", + "Planora.Collaboration.Infrastructure", "Planora.Auth.Api", "Planora.Todo.Api", "Planora.Category.Api", "Planora.Messaging.Api", "Planora.Realtime.Api", + "Planora.Collaboration.Api", }; [Fact] diff --git a/tests/Planora.UnitTests/Planora.UnitTests.csproj b/tests/Planora.UnitTests/Planora.UnitTests.csproj index ca81f6a0..8d72ffdd 100644 --- a/tests/Planora.UnitTests/Planora.UnitTests.csproj +++ b/tests/Planora.UnitTests/Planora.UnitTests.csproj @@ -40,6 +40,10 @@ + + + + diff --git a/tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemCommentTests.cs b/tests/Planora.UnitTests/Services/CollaborationApi/Domain/CommentTests.cs similarity index 55% rename from tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemCommentTests.cs rename to tests/Planora.UnitTests/Services/CollaborationApi/Domain/CommentTests.cs index 8e0525ab..11306f8e 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemCommentTests.cs +++ b/tests/Planora.UnitTests/Services/CollaborationApi/Domain/CommentTests.cs @@ -1,12 +1,12 @@ using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.Todo.Domain.Entities; -using Planora.Todo.Domain.Events; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Events; -namespace Planora.UnitTests.Services.TodoApi.Domain; +namespace Planora.UnitTests.Services.CollaborationApi.Domain; -public class TodoItemCommentTests +public class CommentTests { - private static readonly Guid _todoId = Guid.NewGuid(); + private static readonly Guid _taskId = Guid.NewGuid(); private static readonly Guid _authorId = Guid.NewGuid(); // ─── Create ─────────────────────────────────────────────────────────────── @@ -14,25 +14,26 @@ public class TodoItemCommentTests [Fact] public void Create_ShouldProduceValidComment() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Hello world"); + var comment = Comment.Create(_taskId, _authorId, "Alice", "Hello world"); Assert.NotEqual(Guid.Empty, comment.Id); - Assert.Equal(_todoId, comment.TodoItemId); + Assert.Equal(_taskId, comment.TaskId); Assert.Equal(_authorId, comment.AuthorId); Assert.Equal("Alice", comment.AuthorName); Assert.Equal("Hello world", comment.Content); Assert.NotEqual(default, comment.CreatedAt); - // Fresh comment must NOT have UpdatedAt set and must NOT be marked as edited Assert.Null(comment.UpdatedAt); Assert.False(comment.IsEdited); + Assert.False(comment.IsSystemComment); + Assert.False(comment.IsGenesisComment); Assert.Contains(comment.DomainEvents, - e => e is TodoCommentAddedDomainEvent ev && ev.CommentId == comment.Id); + e => e is CommentAddedDomainEvent ev && ev.CommentId == comment.Id && ev.TaskId == _taskId); } [Fact] public void Create_ShouldTrimWhitespace() { - var comment = TodoItemComment.Create(_todoId, _authorId, " Bob ", " Hi "); + var comment = Comment.Create(_taskId, _authorId, " Bob ", " Hi "); Assert.Equal("Bob", comment.AuthorName); Assert.Equal("Hi", comment.Content); @@ -42,9 +43,9 @@ public void Create_ShouldTrimWhitespace() public void Create_WithEmptyContent_ShouldThrow() { Assert.Throws(() => - TodoItemComment.Create(_todoId, _authorId, "Author", "")); + Comment.Create(_taskId, _authorId, "Author", "")); Assert.Throws(() => - TodoItemComment.Create(_todoId, _authorId, "Author", " ")); + Comment.Create(_taskId, _authorId, "Author", " ")); } [Fact] @@ -52,14 +53,14 @@ public void Create_WithContentOver2000Chars_ShouldThrow() { var longContent = new string('x', 2001); Assert.Throws(() => - TodoItemComment.Create(_todoId, _authorId, "Author", longContent)); + Comment.Create(_taskId, _authorId, "Author", longContent)); } [Fact] public void Create_WithExactly2000Chars_ShouldSucceed() { var maxContent = new string('x', 2000); - var comment = TodoItemComment.Create(_todoId, _authorId, "Author", maxContent); + var comment = Comment.Create(_taskId, _authorId, "Author", maxContent); Assert.Equal(2000, comment.Content.Length); } @@ -67,89 +68,94 @@ public void Create_WithExactly2000Chars_ShouldSucceed() public void Create_WithEmptyAuthorName_ShouldThrow() { Assert.Throws(() => - TodoItemComment.Create(_todoId, _authorId, " ", "Valid content")); + Comment.Create(_taskId, _authorId, " ", "Valid content")); } [Fact] - public void Create_WithEmptyTodoId_ShouldThrow() + public void Create_WithEmptyTaskId_ShouldThrow() { Assert.Throws(() => - TodoItemComment.Create(Guid.Empty, _authorId, "Author", "Content")); + Comment.Create(Guid.Empty, _authorId, "Author", "Content")); } [Fact] public void Create_WithEmptyAuthorId_ShouldThrow() { Assert.Throws(() => - TodoItemComment.Create(_todoId, Guid.Empty, "Author", "Content")); + Comment.Create(_taskId, Guid.Empty, "Author", "Content")); } - // ─── UpdateContent ──────────────────────────────────────────────────────── + // ─── System / Genesis ─────────────────────────────────────────────────────── [Fact] - public void UpdateContent_ByAuthor_ShouldUpdateAndMarkModified() + public void CreateSystem_ShouldMarkSystemAndRaiseNoDomainEvent() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Original"); + var comment = Comment.CreateSystem(_taskId, "Alice created the task"); - comment.UpdateContent("Updated content", _authorId); - - Assert.Equal("Updated content", comment.Content); - Assert.NotNull(comment.UpdatedAt); - Assert.Equal(_authorId, comment.UpdatedBy); + Assert.True(comment.IsSystemComment); + Assert.False(comment.IsGenesisComment); + Assert.Equal(Guid.Empty, comment.AuthorId); + Assert.Empty(comment.DomainEvents); } [Fact] - public void UpdateContent_ByNonAuthor_ShouldThrowForbidden() + public void CreateGenesis_ShouldMarkSystemAndGenesis() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Original"); + var comment = Comment.CreateGenesis(_taskId, "The task description", "Alice"); - Assert.Throws(() => - comment.UpdateContent("Hacked content", Guid.NewGuid())); + Assert.True(comment.IsSystemComment); + Assert.True(comment.IsGenesisComment); + Assert.Equal("Alice", comment.AuthorName); + Assert.Equal("The task description", comment.Content); + Assert.Empty(comment.DomainEvents); } [Fact] - public void UpdateContent_WithEmptyContent_ShouldThrow() + public void CreateGenesis_WithContentOver5000Chars_ShouldThrow() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Original"); - + var longContent = new string('x', 5001); Assert.Throws(() => - comment.UpdateContent("", _authorId)); + Comment.CreateGenesis(_taskId, longContent, "Alice")); } [Fact] - public void UpdateContent_WithContentOver2000Chars_ShouldThrow() + public void UpdateGenesisContent_OnRegularComment_ShouldThrow() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Original"); - var longContent = new string('x', 2001); - - Assert.Throws(() => - comment.UpdateContent(longContent, _authorId)); + var comment = Comment.Create(_taskId, _authorId, "Alice", "Regular"); + Assert.Throws(() => comment.UpdateGenesisContent("x", _authorId)); } - // ─── IsEdited ───────────────────────────────────────────────────────────── + // ─── UpdateContent ──────────────────────────────────────────────────────── [Fact] - public void IsEdited_FreshComment_ShouldBeFalse() + public void UpdateContent_ByAuthor_ShouldUpdateAndMarkModified() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Hello"); - Assert.False(comment.IsEdited); + var comment = Comment.Create(_taskId, _authorId, "Alice", "Original"); + + comment.UpdateContent("Updated content", _authorId); + + Assert.Equal("Updated content", comment.Content); + Assert.NotNull(comment.UpdatedAt); + Assert.Equal(_authorId, comment.UpdatedBy); } [Fact] - public void IsEdited_AfterUpdateContent_ShouldBeTrue_WhenEnoughTimeHasPassed() + public void UpdateContent_ByNonAuthor_ShouldThrowForbidden() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Original"); + var comment = Comment.Create(_taskId, _authorId, "Alice", "Original"); - // Simulate that update happens more than 5 seconds after creation - // by directly inspecting the logic: UpdatedAt > CreatedAt + 5s - // We test the property logic indirectly through the domain method - // and trust the 5s window handles rapid back-to-back calls. - comment.UpdateContent("Updated", _authorId); + Assert.Throws(() => + comment.UpdateContent("Hacked content", Guid.NewGuid())); + } - // UpdatedAt is now set; CreatedAt was set milliseconds ago — IsEdited is false - // because delta < 5s. This is by design (5s grace window). - // Verify UpdatedAt was set though. - Assert.NotNull(comment.UpdatedAt); + [Fact] + public void UpdateContent_WithContentOver2000Chars_ShouldThrow() + { + var comment = Comment.Create(_taskId, _authorId, "Alice", "Original"); + var longContent = new string('x', 2001); + + Assert.Throws(() => + comment.UpdateContent(longContent, _authorId)); } // ─── SoftDelete ─────────────────────────────────────────────────────────── @@ -157,7 +163,7 @@ public void IsEdited_AfterUpdateContent_ShouldBeTrue_WhenEnoughTimeHasPassed() [Fact] public void MarkAsDeleted_ShouldSetIsDeletedAndTimestamp() { - var comment = TodoItemComment.Create(_todoId, _authorId, "Alice", "Hello"); + var comment = Comment.Create(_taskId, _authorId, "Alice", "Hello"); var deleterId = Guid.NewGuid(); comment.MarkAsDeleted(deleterId); diff --git a/tests/Planora.UnitTests/Services/TodoApi/Controllers/TodosWorkerCommentControllerTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Controllers/TodosWorkerCommentControllerTests.cs deleted file mode 100644 index 8e720de4..00000000 --- a/tests/Planora.UnitTests/Services/TodoApi/Controllers/TodosWorkerCommentControllerTests.cs +++ /dev/null @@ -1,337 +0,0 @@ -using Planora.BuildingBlocks.Application.Pagination; -using Planora.BuildingBlocks.Domain; -using Planora.Todo.Api.Controllers; -using Planora.Todo.Application.DTOs; -using Planora.Todo.Application.Features.Todos.Commands.AddComment; -using Planora.Todo.Application.Features.Todos.Commands.DeleteComment; -using Planora.Todo.Application.Features.Todos.Commands.JoinTodo; -using Planora.Todo.Application.Features.Todos.Commands.LeaveTodo; -using Planora.Todo.Application.Features.Todos.Commands.UpdateComment; -using Planora.Todo.Application.Features.Todos.Queries.GetComments; -using Planora.Todo.Domain.Enums; -using MediatR; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using DomainResult = Planora.BuildingBlocks.Domain.Result; -using CommentResult = Planora.BuildingBlocks.Domain.Result; -using PagedCommentResult = Planora.BuildingBlocks.Domain.Result>; -using TodoResult = Planora.BuildingBlocks.Domain.Result; - -namespace Planora.UnitTests.Services.TodoApi.Controllers; - -public class TodosWorkerCommentControllerTests -{ - // ─── JoinTodo ───────────────────────────────────────────────────────────── - - [Fact] - public async Task JoinTodo_ReturnsOk_OnSuccess() - { - var todoId = Guid.NewGuid(); - var dto = MakeTodoDto(todoId, isWorking: true, workerCount: 1); - var mediator = SetupMediator(TodoResult.Success(dto)); - var controller = CreateController(mediator); - - var result = await controller.JoinTodo(todoId, CancellationToken.None); - - var ok = Assert.IsType(result.Result); - Assert.Same(dto, ok.Value); - } - - [Fact] - public async Task JoinTodo_ReturnsBadRequest_OnFailure() - { - var mediator = SetupMediator( - TodoResult.Failure("CAPACITY_FULL", "Task is full")); - var controller = CreateController(mediator); - - var result = await controller.JoinTodo(Guid.NewGuid(), CancellationToken.None); - - Assert.IsType(result.Result); - } - - [Fact] - public async Task JoinTodo_SendsCommandWithCorrectTodoId() - { - var todoId = Guid.NewGuid(); - JoinTodoCommand? sent = null; - var mediator = new Mock(); - mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .Callback, CancellationToken>((cmd, _) => sent = (JoinTodoCommand)cmd) - .ReturnsAsync(TodoResult.Success(MakeTodoDto(todoId))); - var controller = CreateController(mediator); - - await controller.JoinTodo(todoId, CancellationToken.None); - - Assert.NotNull(sent); - Assert.Equal(todoId, sent.TodoId); - } - - // ─── LeaveTodo ──────────────────────────────────────────────────────────── - - [Fact] - public async Task LeaveTodo_ReturnsNoContent_OnSuccess() - { - var mediator = SetupMediator(DomainResult.Success()); - var controller = CreateController(mediator); - - var result = await controller.LeaveTodo(Guid.NewGuid(), CancellationToken.None); - - Assert.IsType(result); - } - - [Fact] - public async Task LeaveTodo_ReturnsBadRequest_OnFailure() - { - var mediator = SetupMediator( - DomainResult.Failure("NOT_A_WORKER", "You are not a worker")); - var controller = CreateController(mediator); - - var result = await controller.LeaveTodo(Guid.NewGuid(), CancellationToken.None); - - Assert.IsType(result); - } - - // ─── GetComments ────────────────────────────────────────────────────────── - - [Fact] - public async Task GetComments_ReturnsOk_WithPagedResult() - { - var todoId = Guid.NewGuid(); - var comments = new PagedResult([MakeCommentDto()], 1, 50, 1); - var mediator = SetupMediator(PagedCommentResult.Success(comments)); - var controller = CreateController(mediator); - - var result = await controller.GetComments(todoId, 1, 50, CancellationToken.None); - - var ok = Assert.IsType(result.Result); - Assert.Same(comments, ok.Value); - } - - [Fact] - public async Task GetComments_SendsQueryWithCorrectParameters() - { - var todoId = Guid.NewGuid(); - GetCommentsQuery? sent = null; - var mediator = new Mock(); - mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .Callback, CancellationToken>((q, _) => sent = (GetCommentsQuery)q) - .ReturnsAsync(PagedCommentResult.Success(new PagedResult([], 2, 25, 0))); - var controller = CreateController(mediator); - - await controller.GetComments(todoId, 2, 25, CancellationToken.None); - - Assert.NotNull(sent); - Assert.Equal(todoId, sent.TodoId); - Assert.Equal(2, sent.PageNumber); - Assert.Equal(25, sent.PageSize); - } - - [Fact] - public async Task GetComments_ReturnsBadRequest_OnFailure() - { - var mediator = SetupMediator( - PagedCommentResult.Failure("AUTH_REQUIRED", "Not authenticated")); - var controller = CreateController(mediator); - - var result = await controller.GetComments(Guid.NewGuid(), 1, 50, CancellationToken.None); - - Assert.IsType(result.Result); - } - - // ─── AddComment ─────────────────────────────────────────────────────────── - - [Fact] - public async Task AddComment_Returns201Created_OnSuccess() - { - var commentDto = MakeCommentDto(); - var mediator = SetupMediator(CommentResult.Success(commentDto)); - var controller = CreateController(mediator); - - var result = await controller.AddComment( - Guid.NewGuid(), - new global::AddCommentRequest("Hello!"), - CancellationToken.None); - - var created = Assert.IsType(result.Result); - Assert.Equal(StatusCodes.Status201Created, created.StatusCode); - Assert.Same(commentDto, created.Value); - } - - [Fact] - public async Task AddComment_SendsCommandWithCorrectContent() - { - var todoId = Guid.NewGuid(); - AddCommentCommand? sent = null; - var mediator = new Mock(); - mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .Callback, CancellationToken>((cmd, _) => sent = (AddCommentCommand)cmd) - .ReturnsAsync(CommentResult.Success(MakeCommentDto())); - var controller = CreateController(mediator); - - await controller.AddComment(todoId, new global::AddCommentRequest("My comment"), CancellationToken.None); - - Assert.NotNull(sent); - Assert.Equal(todoId, sent.TodoId); - Assert.Equal("My comment", sent.Content); - } - - [Fact] - public async Task AddComment_ReturnsBadRequest_OnFailure() - { - var mediator = SetupMediator( - CommentResult.Failure("NOT_A_WORKER", "You must be a worker to comment")); - var controller = CreateController(mediator); - - var result = await controller.AddComment( - Guid.NewGuid(), - new global::AddCommentRequest("content"), - CancellationToken.None); - - Assert.IsType(result.Result); - } - - // ─── UpdateComment ──────────────────────────────────────────────────────── - - [Fact] - public async Task UpdateComment_ReturnsOk_OnSuccess() - { - var commentDto = MakeCommentDto(); - var mediator = SetupMediator(CommentResult.Success(commentDto)); - var controller = CreateController(mediator); - - var result = await controller.UpdateComment( - Guid.NewGuid(), - Guid.NewGuid(), - new global::UpdateCommentRequest("Updated"), - CancellationToken.None); - - var ok = Assert.IsType(result.Result); - Assert.Same(commentDto, ok.Value); - } - - [Fact] - public async Task UpdateComment_SendsCommandWithCorrectIds() - { - var todoId = Guid.NewGuid(); - var commentId = Guid.NewGuid(); - UpdateCommentCommand? sent = null; - var mediator = new Mock(); - mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .Callback, CancellationToken>((cmd, _) => sent = (UpdateCommentCommand)cmd) - .ReturnsAsync(CommentResult.Success(MakeCommentDto())); - var controller = CreateController(mediator); - - await controller.UpdateComment(todoId, commentId, - new global::UpdateCommentRequest("New content"), CancellationToken.None); - - Assert.NotNull(sent); - Assert.Equal(todoId, sent.TodoId); - Assert.Equal(commentId, sent.CommentId); - Assert.Equal("New content", sent.Content); - } - - [Fact] - public async Task UpdateComment_ReturnsBadRequest_OnFailure() - { - var mediator = SetupMediator( - CommentResult.Failure("FORBIDDEN", "Not author")); - var controller = CreateController(mediator); - - var result = await controller.UpdateComment( - Guid.NewGuid(), Guid.NewGuid(), - new global::UpdateCommentRequest("x"), CancellationToken.None); - - Assert.IsType(result.Result); - } - - // ─── DeleteComment ──────────────────────────────────────────────────────── - - [Fact] - public async Task DeleteComment_ReturnsNoContent_OnSuccess() - { - var mediator = SetupMediator(DomainResult.Success()); - var controller = CreateController(mediator); - - var result = await controller.DeleteComment(Guid.NewGuid(), Guid.NewGuid(), CancellationToken.None); - - Assert.IsType(result); - } - - [Fact] - public async Task DeleteComment_SendsCommandWithCorrectIds() - { - var todoId = Guid.NewGuid(); - var commentId = Guid.NewGuid(); - DeleteCommentCommand? sent = null; - var mediator = new Mock(); - mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .Callback, CancellationToken>((cmd, _) => sent = (DeleteCommentCommand)cmd) - .ReturnsAsync(DomainResult.Success()); - var controller = CreateController(mediator); - - await controller.DeleteComment(todoId, commentId, CancellationToken.None); - - Assert.NotNull(sent); - Assert.Equal(todoId, sent.TodoId); - Assert.Equal(commentId, sent.CommentId); - } - - [Fact] - public async Task DeleteComment_ReturnsBadRequest_OnFailure() - { - var mediator = SetupMediator( - DomainResult.Failure("FORBIDDEN", "Not authorized")); - var controller = CreateController(mediator); - - var result = await controller.DeleteComment(Guid.NewGuid(), Guid.NewGuid(), CancellationToken.None); - - Assert.IsType(result); - } - - // ─── Helpers ────────────────────────────────────────────────────────────── - - private static Mock SetupMediator(TResponse response) - where TRequest : IRequest - { - var mediator = new Mock(); - mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) - .ReturnsAsync(response); - return mediator; - } - - private static TodosController CreateController(Mock mediator) - => new(mediator.Object, new Mock>().Object) - { - ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } - }; - - private static TodoItemDto MakeTodoDto(Guid? id = null, bool isWorking = false, int workerCount = 0) => new() - { - Id = id ?? Guid.NewGuid(), - UserId = Guid.NewGuid(), - Title = "Task", - Status = "todo", - Priority = TodoPriority.Medium.ToString(), - IsPublic = true, - Hidden = false, - IsCompleted = false, - Tags = [], - CreatedAt = DateTime.UtcNow, - IsWorking = isWorking, - WorkerCount = workerCount, - }; - - private static TodoCommentDto MakeCommentDto() => new( - Id: Guid.NewGuid(), - TodoItemId: Guid.NewGuid(), - AuthorId: Guid.NewGuid(), - AuthorName: "Author", - AuthorAvatarUrl: null, - Content: "A comment", - CreatedAt: DateTime.UtcNow, - UpdatedAt: null, - IsOwn: true, - IsEdited: false); -} diff --git a/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs index 93446828..3859c618 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs +++ b/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs @@ -647,7 +647,7 @@ private sealed class TodoCommandFixture { public Mock> GenericRepository { get; } = new(); public Mock TodoRepository { get; } = new(); - public Mock CommentRepository { get; } = new(); + public Mock OutboxRepository { get; } = new(); public Mock UnitOfWork { get; } = new(); public Mock Mapper { get; } = new(); public Mock CurrentUser { get; } = new(); @@ -672,7 +672,7 @@ public CreateTodoCommandHandler CreateCreateHandler() CurrentUser.Object, CategoryGrpcClient.Object, FriendshipService.Object, - Mock.Of()); + Mock.Of()); public UpdateTodoCommandHandler CreateUpdateHandler() => new( @@ -684,7 +684,7 @@ public UpdateTodoCommandHandler CreateUpdateHandler() CategoryGrpcClient.Object, FriendshipService.Object, ViewerPreferences.Object, - Mock.Of()); + Mock.Of()); public SetTodoHiddenCommandHandler CreateSetHiddenHandler() => new( @@ -708,7 +708,7 @@ public SetViewerPreferenceCommandHandler CreateSetViewerPreferenceHandler() public DeleteTodoCommandHandler CreateDeleteHandler() => new( GenericRepository.Object, - CommentRepository.Object, + OutboxRepository.Object, UnitOfWork.Object, Mock.Of>(), CurrentUser.Object); diff --git a/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoOwnershipHandlerTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoOwnershipHandlerTests.cs index da11226d..c9303176 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoOwnershipHandlerTests.cs +++ b/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoOwnershipHandlerTests.cs @@ -41,7 +41,7 @@ public async Task CreateTodo_ShouldRejectSharingWithNonFriend_AndNotSave() currentUserContextMock.Object, categoryGrpcClientMock.Object, friendshipServiceMock.Object, - Mock.Of()); + Mock.Of()); var command = new CreateTodoCommand( null, @@ -91,7 +91,7 @@ public async Task UpdateTodo_ShouldRejectCategoryNotOwnedByCurrentUser() categoryGrpcClientMock.Object, Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of()); await Assert.ThrowsAsync(() => handler.Handle( new UpdateTodoCommand(todo.Id, CategoryId: foreignCategoryId), @@ -179,7 +179,7 @@ public async Task UpdateTodo_ShouldRejectSharedMetadataEdit_EvenWhenUsersAreFrie Mock.Of(), friendshipServiceMock.Object, Mock.Of(), - Mock.Of()); + Mock.Of()); await Assert.ThrowsAsync(() => handler.Handle( new UpdateTodoCommand(todo.Id, Title: "Changed by viewer"), diff --git a/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs deleted file mode 100644 index c5fd011f..00000000 --- a/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs +++ /dev/null @@ -1,682 +0,0 @@ -using AutoMapper; -using Planora.BuildingBlocks.Application.Pagination; -using Planora.BuildingBlocks.Domain; -using Planora.BuildingBlocks.Domain.Exceptions; -using Planora.BuildingBlocks.Domain.Interfaces; -using Planora.BuildingBlocks.Application.Context; -using Planora.Todo.Application.DTOs; -using Planora.Todo.Application.Features.Todos.Commands.AddComment; -using Planora.Todo.Application.Features.Todos.Commands.DeleteComment; -using Planora.Todo.Application.Features.Todos.Commands.JoinTodo; -using Planora.Todo.Application.Features.Todos.Commands.LeaveTodo; -using Planora.Todo.Application.Features.Todos.Commands.UpdateComment; -using Planora.Todo.Application.Features.Todos.Queries.GetComments; -using Planora.Todo.Application.Services; -using Planora.Todo.Domain.Entities; -using Planora.Todo.Domain.Repositories; -using Microsoft.Extensions.Logging; -using Moq; - -namespace Planora.UnitTests.Services.TodoApi.Handlers; - -public class WorkersAndCommentsHandlerTests -{ - // ═══════════════════════════════════════════════════════════════════════════ - // JoinTodo - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task JoinTodo_ShouldAddWorkerAndReturnUpdatedDto() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Public task", isPublic: true); - var fixture = new WorkerFixture(workerId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(workerId, ownerId, It.IsAny())) - .ReturnsAsync(true); - fixture.Mapper.Setup(x => x.Map(It.IsAny())).Returns(EmptyDto()); - - var result = await fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Single(todo.Workers); - Assert.Equal(1, result.Value!.WorkerCount); - Assert.True(result.Value.IsWorking); - } - - [Fact] - public async Task JoinTodo_WhenOwner_ShouldReturnSuccessWithIsWorking() - { - var ownerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new WorkerFixture(ownerId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.Mapper.Setup(x => x.Map(It.IsAny())).Returns(EmptyDto()); - - var result = await fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.True(result.Value!.IsWorking); - fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task JoinTodo_WhenNoAccess_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - // Private task, not shared with workerId - var todo = TodoItem.Create(ownerId, "Private task"); - var fixture = new WorkerFixture(workerId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - await Assert.ThrowsAsync(() => - fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None)); - } - - [Fact] - public async Task JoinTodo_WhenNotFriends_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - // Non-public task shared with workerId — requires friendship check - var todo = TodoItem.Create(ownerId, "Shared task", sharedWithUserIds: new[] { workerId }); - var fixture = new WorkerFixture(workerId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(workerId, ownerId, It.IsAny())) - .ReturnsAsync(false); - - await Assert.ThrowsAsync(() => - fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None)); - } - - [Fact] - public async Task JoinTodo_WhenAlreadyWorker_ShouldReturnIdempotentSuccess() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Public task", isPublic: true); - todo.AddWorker(workerId); - - var fixture = new WorkerFixture(workerId); - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.Mapper.Setup(x => x.Map(It.IsAny())).Returns(EmptyDto()); - - var result = await fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.True(result.Value!.IsWorking); - fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task JoinTodo_PublicTask_ShouldSucceedWithoutFriendshipCheck() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Public task", isPublic: true); - var fixture = new WorkerFixture(workerId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.Mapper.Setup(x => x.Map(It.IsAny())).Returns(EmptyDto()); - - var result = await fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - fixture.FriendshipService.Verify( - x => x.AreFriendsAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task JoinTodo_WhenNotFound_ShouldThrowEntityNotFound() - { - var fixture = new WorkerFixture(Guid.NewGuid()); - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((TodoItem?)null); - - await Assert.ThrowsAsync(() => - fixture.CreateJoinHandler().Handle(new JoinTodoCommand(Guid.NewGuid()), CancellationToken.None)); - } - - [Fact] - public async Task JoinTodo_WhenCapacityFull_ShouldThrowBusinessRule() - { - var ownerId = Guid.NewGuid(); - var existingWorker = Guid.NewGuid(); - var newWorker = Guid.NewGuid(); - // RequiredWorkers = 2 → only 1 non-owner slot - var todo = TodoItem.Create(ownerId, "Task", isPublic: true, requiredWorkers: 2); - todo.AddWorker(existingWorker); - - var fixture = new WorkerFixture(newWorker); - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(newWorker, ownerId, It.IsAny())) - .ReturnsAsync(true); - - await Assert.ThrowsAsync(() => - fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // LeaveTodo - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task LeaveTodo_ShouldRemoveWorkerAndReturnSuccess() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - todo.AddWorker(workerId); - - var fixture = new WorkerFixture(workerId); - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - var result = await fixture.CreateLeaveHandler().Handle(new LeaveTodoCommand(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Empty(todo.Workers); - } - - [Fact] - public async Task LeaveTodo_WhenOwner_ShouldThrowBusinessRule() - { - var ownerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task"); - var fixture = new WorkerFixture(ownerId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - await Assert.ThrowsAsync(() => - fixture.CreateLeaveHandler().Handle(new LeaveTodoCommand(todo.Id), CancellationToken.None)); - } - - [Fact] - public async Task LeaveTodo_WhenNotWorker_ShouldThrowEntityNotFound() - { - var ownerId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new WorkerFixture(userId); - - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - await Assert.ThrowsAsync(() => - fixture.CreateLeaveHandler().Handle(new LeaveTodoCommand(todo.Id), CancellationToken.None)); - } - - [Fact] - public async Task JoinTodo_ShouldCreateSystemComment_WithStartedWorkingText() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new WorkerFixture(workerId, "Alice"); - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.Mapper.Setup(x => x.Map(It.IsAny())).Returns(EmptyDto()); - - await fixture.CreateJoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None); - - fixture.CommentRepository.Verify( - x => x.AddAsync( - It.Is(c => c.IsSystemComment && c.Content.Contains("Alice") && c.Content.Contains("started working")), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task LeaveTodo_ShouldCreateSystemComment_WhenWorkerLeaves() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - todo.AddWorker(workerId); - var fixture = new WorkerFixture(workerId, "Bob"); - fixture.Repository.Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - var result = await fixture.CreateLeaveHandler().Handle(new LeaveTodoCommand(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Empty(todo.Workers); - fixture.CommentRepository.Verify( - x => x.AddAsync( - It.Is(c => c.IsSystemComment && c.Content.Contains("Bob") && c.Content.Contains("left")), - It.IsAny()), - Times.Once); - fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // AddComment - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task AddComment_ByOwner_ShouldSucceed() - { - var ownerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new CommentFixture(ownerId, "Owner Name"); - - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - var result = await fixture.CreateAddHandler().Handle( - new AddCommentCommand(todo.Id, "Great task!"), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Equal("Great task!", result.Value!.Content); - Assert.True(result.Value.IsOwn); - Assert.False(result.Value.IsEdited); - Assert.Null(result.Value.UpdatedAt); - } - - [Fact] - public async Task AddComment_ByWorkerWhoIsFriend_ShouldSucceed() - { - var ownerId = Guid.NewGuid(); - var workerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - todo.AddWorker(workerId); - - var fixture = new CommentFixture(workerId, "Worker Name"); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - // Worker must also be a friend to comment — public visibility alone is not enough - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(workerId, ownerId, It.IsAny())) - .ReturnsAsync(true); - - var result = await fixture.CreateAddHandler().Handle( - new AddCommentCommand(todo.Id, "My comment"), CancellationToken.None); - - Assert.True(result.IsSuccess); - } - - [Fact] - public async Task AddComment_ByFriendWithPublicAccess_ShouldSucceed() - { - var ownerId = Guid.NewGuid(); - var friendId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - - var fixture = new CommentFixture(friendId, "Friend"); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - // Public task + friendship = can comment - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(friendId, ownerId, It.IsAny())) - .ReturnsAsync(true); - - var result = await fixture.CreateAddHandler().Handle( - new AddCommentCommand(todo.Id, "Nice task!"), CancellationToken.None); - - Assert.True(result.IsSuccess); - } - - [Fact] - public async Task AddComment_ByNonFriendWithPublicAccess_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var viewerId = Guid.NewGuid(); // not a worker, not in SharedWith, not a friend - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - - var fixture = new CommentFixture(viewerId, "Viewer"); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(viewerId, ownerId, It.IsAny())) - .ReturnsAsync(false); - - await Assert.ThrowsAsync(() => - fixture.CreateAddHandler().Handle( - new AddCommentCommand(todo.Id, "Can I comment?"), CancellationToken.None)); - } - - [Fact] - public async Task AddComment_WithNoAccess_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var strangerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Private task"); // not public, not shared - var fixture = new CommentFixture(strangerId, "Stranger"); - - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - await Assert.ThrowsAsync(() => - fixture.CreateAddHandler().Handle( - new AddCommentCommand(todo.Id, "Hack!"), CancellationToken.None)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // UpdateComment - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task UpdateComment_ByAuthor_ShouldReturnUpdatedDto() - { - var authorId = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var comment = TodoItemComment.Create(todoId, authorId, "Alice", "Original"); - var fixture = new CommentFixture(authorId, "Alice"); - - fixture.CommentRepository.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())) - .ReturnsAsync(comment); - - var result = await fixture.CreateUpdateHandler().Handle( - new UpdateCommentCommand(todoId, comment.Id, "Updated content"), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Equal("Updated content", result.Value!.Content); - Assert.True(result.Value.IsOwn); - } - - [Fact] - public async Task UpdateComment_ByNonAuthor_ShouldThrowForbidden() - { - var authorId = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var comment = TodoItemComment.Create(todoId, authorId, "Alice", "Original"); - var otherUser = Guid.NewGuid(); - var fixture = new CommentFixture(otherUser, "Bob"); - - fixture.CommentRepository.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())) - .ReturnsAsync(comment); - - await Assert.ThrowsAsync(() => - fixture.CreateUpdateHandler().Handle( - new UpdateCommentCommand(todoId, comment.Id, "Hacked"), CancellationToken.None)); - } - - [Fact] - public async Task UpdateComment_WithWrongTodoId_ShouldThrowEntityNotFound() - { - var authorId = Guid.NewGuid(); - var correctTodoId = Guid.NewGuid(); - var wrongTodoId = Guid.NewGuid(); - var comment = TodoItemComment.Create(correctTodoId, authorId, "Alice", "Content"); - var fixture = new CommentFixture(authorId, "Alice"); - - fixture.CommentRepository.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())) - .ReturnsAsync(comment); - - await Assert.ThrowsAsync(() => - fixture.CreateUpdateHandler().Handle( - new UpdateCommentCommand(wrongTodoId, comment.Id, "Content"), CancellationToken.None)); - } - - [Fact] - public async Task UpdateComment_WhenNotFound_ShouldThrowEntityNotFound() - { - var fixture = new CommentFixture(Guid.NewGuid(), "User"); - fixture.CommentRepository.Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((TodoItemComment?)null); - - await Assert.ThrowsAsync(() => - fixture.CreateUpdateHandler().Handle( - new UpdateCommentCommand(Guid.NewGuid(), Guid.NewGuid(), "Content"), CancellationToken.None)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // DeleteComment - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task DeleteComment_ByAuthor_ShouldSoftDelete() - { - var ownerId = Guid.NewGuid(); - var authorId = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var comment = TodoItemComment.Create(todoId, authorId, "Alice", "Delete me"); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new CommentFixture(authorId, "Alice"); - - fixture.CommentRepository.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())) - .ReturnsAsync(comment); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todoId, It.IsAny())) - .ReturnsAsync(todo); - - var result = await fixture.CreateDeleteHandler().Handle( - new DeleteCommentCommand(todoId, comment.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.True(comment.IsDeleted); - } - - [Fact] - public async Task DeleteComment_ByTodoOwner_ShouldSoftDelete() - { - var ownerId = Guid.NewGuid(); - var authorId = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var comment = TodoItemComment.Create(todoId, authorId, "Alice", "Inappropriate"); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new CommentFixture(ownerId, "Owner"); - - fixture.CommentRepository.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())) - .ReturnsAsync(comment); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todoId, It.IsAny())) - .ReturnsAsync(todo); - - var result = await fixture.CreateDeleteHandler().Handle( - new DeleteCommentCommand(todoId, comment.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.True(comment.IsDeleted); - } - - [Fact] - public async Task DeleteComment_ByUnauthorizedUser_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var authorId = Guid.NewGuid(); - var thirdParty = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var comment = TodoItemComment.Create(todoId, authorId, "Alice", "Content"); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new CommentFixture(thirdParty, "Third"); - - fixture.CommentRepository.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())) - .ReturnsAsync(comment); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todoId, It.IsAny())) - .ReturnsAsync(todo); - - await Assert.ThrowsAsync(() => - fixture.CreateDeleteHandler().Handle( - new DeleteCommentCommand(todoId, comment.Id), CancellationToken.None)); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // GetComments - // ═══════════════════════════════════════════════════════════════════════════ - - [Fact] - public async Task GetComments_ByOwner_ShouldReturnPagedComments() - { - var ownerId = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var c1 = TodoItemComment.Create(todoId, ownerId, "Owner", "First comment"); - var c2 = TodoItemComment.Create(todoId, Guid.NewGuid(), "Worker", "Second comment"); - - var fixture = new CommentFixture(ownerId, "Owner"); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.CommentRepository.Setup(x => x.GetPagedByTodoIdAsync(todo.Id, 1, 50, It.IsAny())) - .ReturnsAsync(((IReadOnlyList)[c1, c2], 2)); - - var result = await fixture.CreateGetCommentsHandler().Handle( - new GetCommentsQuery(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Equal(2, result.Value!.Items.Count); - Assert.True(result.Value.Items[0].IsOwn); // c1 is by owner - Assert.False(result.Value.Items[1].IsOwn); // c2 is by someone else - } - - [Fact] - public async Task GetComments_ByFriendWithPublicAccess_ShouldReturnPagedComments() - { - var ownerId = Guid.NewGuid(); - var friendId = Guid.NewGuid(); - var todoId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var comment = TodoItemComment.Create(todoId, ownerId, "Owner", "Visible comment"); - - var fixture = new CommentFixture(friendId, "Friend"); - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.CommentRepository.Setup(x => x.GetPagedByTodoIdAsync(todo.Id, 1, 50, It.IsAny())) - .ReturnsAsync(((IReadOnlyList)[comment], 1)); - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(friendId, ownerId, It.IsAny())) - .ReturnsAsync(true); - - var result = await fixture.CreateGetCommentsHandler().Handle( - new GetCommentsQuery(todo.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Single(result.Value!.Items); - Assert.False(result.Value.Items[0].IsOwn); - } - - [Fact] - public async Task GetComments_ByNonFriendWithPublicAccess_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var viewerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Task", isPublic: true); - var fixture = new CommentFixture(viewerId, "Viewer"); - - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - fixture.FriendshipService.Setup(x => x.AreFriendsAsync(viewerId, ownerId, It.IsAny())) - .ReturnsAsync(false); - - await Assert.ThrowsAsync(() => - fixture.CreateGetCommentsHandler().Handle( - new GetCommentsQuery(todo.Id), CancellationToken.None)); - } - - [Fact] - public async Task GetComments_WithNoAccess_ShouldThrowForbidden() - { - var ownerId = Guid.NewGuid(); - var strangerId = Guid.NewGuid(); - var todo = TodoItem.Create(ownerId, "Private task"); - var fixture = new CommentFixture(strangerId, "Stranger"); - - fixture.TodoRepository.Setup(x => x.GetByIdWithIncludesAsync(todo.Id, It.IsAny())) - .ReturnsAsync(todo); - - await Assert.ThrowsAsync(() => - fixture.CreateGetCommentsHandler().Handle( - new GetCommentsQuery(todo.Id), CancellationToken.None)); - } - - [Fact] - public async Task GetComments_WithMissingAuthContext_ShouldReturnFailure() - { - var fixture = new CommentFixture(Guid.Empty, null); // Empty userId = unauthenticated - - var result = await fixture.CreateGetCommentsHandler().Handle( - new GetCommentsQuery(Guid.NewGuid()), CancellationToken.None); - - Assert.True(result.IsFailure); - Assert.Equal("AUTH_REQUIRED", result.Error!.Code); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Fixtures - // ═══════════════════════════════════════════════════════════════════════════ - - private sealed class WorkerFixture - { - public Mock Repository { get; } = new(); - public Mock UnitOfWork { get; } = new(); - public Mock Mapper { get; } = new(); - public Mock CurrentUser { get; } = new(); - public Mock FriendshipService { get; } = new(); - public Mock CommentRepository { get; } = new(); - - public WorkerFixture(Guid userId, string? userName = "Worker") - { - CurrentUser.SetupGet(x => x.UserId).Returns(userId); - CurrentUser.SetupGet(x => x.IsAuthenticated).Returns(userId != Guid.Empty); - CurrentUser.SetupGet(x => x.Name).Returns(userName); - UnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); - } - - public JoinTodoCommandHandler CreateJoinHandler() - => new(Repository.Object, UnitOfWork.Object, Mapper.Object, CurrentUser.Object, FriendshipService.Object, - CommentRepository.Object, Mock.Of>()); - - public LeaveTodoCommandHandler CreateLeaveHandler() - => new(Repository.Object, UnitOfWork.Object, CurrentUser.Object, - CommentRepository.Object, Mock.Of>()); - } - - private sealed class CommentFixture - { - public Mock TodoRepository { get; } = new(); - public Mock CommentRepository { get; } = new(); - public Mock UnitOfWork { get; } = new(); - public Mock CurrentUser { get; } = new(); - public Mock FriendshipService { get; } = new(); - public Mock UserService { get; } = new(); - - public CommentFixture(Guid userId, string? name) - { - CurrentUser.SetupGet(x => x.UserId).Returns(userId); - CurrentUser.SetupGet(x => x.IsAuthenticated).Returns(userId != Guid.Empty); - CurrentUser.SetupGet(x => x.Name).Returns(name); - UnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); - CommentRepository.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((TodoItemComment c, CancellationToken _) => c); - CommentRepository.Setup(x => x.Update(It.IsAny())); - // Default: no live avatars available (non-fatal; comments still load) - UserService.Setup(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync(new Dictionary()); - } - - public AddCommentCommandHandler CreateAddHandler() - => new(TodoRepository.Object, CommentRepository.Object, UnitOfWork.Object, CurrentUser.Object, FriendshipService.Object); - - public UpdateCommentCommandHandler CreateUpdateHandler() - => new(CommentRepository.Object, TodoRepository.Object, UnitOfWork.Object, CurrentUser.Object, UserService.Object); - - public DeleteCommentCommandHandler CreateDeleteHandler() - => new(CommentRepository.Object, TodoRepository.Object, UnitOfWork.Object, CurrentUser.Object); - - public GetCommentsQueryHandler CreateGetCommentsHandler() - => new(TodoRepository.Object, CommentRepository.Object, CurrentUser.Object, FriendshipService.Object, UserService.Object); - } - - private static TodoItemDto EmptyDto() => new() - { - Id = Guid.NewGuid(), - UserId = Guid.NewGuid(), - Title = "T", - Status = "todo", - Priority = "Medium", - IsPublic = false, - Hidden = false, - IsCompleted = false, - Tags = [], - CreatedAt = DateTime.UtcNow, - }; -} diff --git a/tests/Planora.UnitTests/Services/TodoApi/Infrastructure/CachingUserServiceTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Infrastructure/CachingUserServiceTests.cs deleted file mode 100644 index d5cf014c..00000000 --- a/tests/Planora.UnitTests/Services/TodoApi/Infrastructure/CachingUserServiceTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Planora.Todo.Application.Services; -using Planora.Todo.Infrastructure.Services; - -namespace Planora.UnitTests.Services.TodoApi.Infrastructure; - -public sealed class CachingUserServiceTests -{ - [Fact] - [Trait("TestType", "Functional")] - public async Task GetUserAvatars_ShouldHitInnerServiceOnceWhenCalledTwiceForSameId() - { - var userId = Guid.NewGuid(); - var inner = new Mock(); - inner.Setup(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync(new Dictionary { [userId] = "/avatars/abc.webp" }); - - var sut = CreateService(inner.Object); - - var first = await sut.GetUserAvatarsAsync(new[] { userId }, CancellationToken.None); - var second = await sut.GetUserAvatarsAsync(new[] { userId }, CancellationToken.None); - - Assert.Equal("/avatars/abc.webp", first[userId]); - Assert.Equal("/avatars/abc.webp", second[userId]); - inner.Verify(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny()), Times.Once); - } - - [Fact] - [Trait("TestType", "Functional")] - public async Task GetUserAvatars_ShouldOnlyFetchMissingIds() - { - var cached = Guid.NewGuid(); - var fresh = Guid.NewGuid(); - var capturedRequests = new List>(); - - var inner = new Mock(); - inner.Setup(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((ids, _) => capturedRequests.Add(ids.ToList())) - .ReturnsAsync((IEnumerable ids, CancellationToken _) => - { - var map = new Dictionary(); - foreach (var id in ids) - { - if (id == cached) map[id] = "/avatars/cached.webp"; - else if (id == fresh) map[id] = "/avatars/fresh.webp"; - } - return map; - }); - - var sut = CreateService(inner.Object); - - await sut.GetUserAvatarsAsync(new[] { cached }, CancellationToken.None); - var second = await sut.GetUserAvatarsAsync(new[] { cached, fresh }, CancellationToken.None); - - Assert.Equal("/avatars/cached.webp", second[cached]); - Assert.Equal("/avatars/fresh.webp", second[fresh]); - Assert.Equal(2, capturedRequests.Count); - Assert.Single(capturedRequests[1]); - Assert.Equal(fresh, capturedRequests[1][0]); - } - - [Fact] - [Trait("TestType", "Functional")] - public async Task GetUserAvatars_ShouldCacheNegativeResultsToAvoidStampede() - { - var unknown = Guid.NewGuid(); - var inner = new Mock(); - inner.Setup(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync(new Dictionary()); - - var sut = CreateService(inner.Object); - - await sut.GetUserAvatarsAsync(new[] { unknown }, CancellationToken.None); - await sut.GetUserAvatarsAsync(new[] { unknown }, CancellationToken.None); - await sut.GetUserAvatarsAsync(new[] { unknown }, CancellationToken.None); - - inner.Verify(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny()), Times.Once); - } - - [Fact] - [Trait("TestType", "Functional")] - public async Task GetUserAvatars_ShouldShortCircuitOnEmptyInput() - { - var inner = new Mock(); - var sut = CreateService(inner.Object); - - var result = await sut.GetUserAvatarsAsync(Array.Empty(), CancellationToken.None); - - Assert.Empty(result); - inner.Verify(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny()), Times.Never); - } - - private static CachingUserService CreateService(IUserService inner) - { - var cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1000 }); - return new CachingUserService(inner, cache, NullLogger.Instance); - } -} From 1093769d2e4ad1afbef9a1ddc30d96612ecd5866 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:04:28 +0000 Subject: [PATCH 03/14] feat(migrator,docs): add Collaboration backfill command and update invariants - Planora.Migrator --backfill-collaboration: idempotent copy of todo.todo_item_comments -> collaboration.comments (run before route cutover) - docs/INVARIANTS.md: INV-OWN-1 adds Collaboration DB; INV-AZ-4 documents that comment-thread friendship authorisation is delegated to TodoApi via the CheckTaskCommentAccess gRPC contract --- docs/INVARIANTS.md | 6 +- .../Planora.Migrator/CollaborationBackfill.cs | 86 +++++++++++++++++++ tools/Planora.Migrator/Program.cs | 46 ++++++++-- 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tools/Planora.Migrator/CollaborationBackfill.cs diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 70ab383e..e6fd1a83 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -10,7 +10,7 @@ This file is short by design. If a rule belongs here, it belongs forever. Items **INV-OWN-1.** Each domain owns its own PostgreSQL database. No service reads or writes another service's tables. -- Domain → DB mapping: Auth → `planora_auth_db`; Todo → `planora_todo`; Category → `planora_category`; Messaging → `planora_messaging`. Realtime currently has no DB (CSP-6). +- Domain → DB mapping: Auth → `planora_auth_db`; Todo → `planora_todo`; Category → `planora_category`; Messaging → `planora_messaging`; Collaboration → `planora_collaboration` (task comment timeline / "ветки"). Realtime currently has no DB (CSP-6). - Cross-service reads happen via gRPC (synchronous) or RabbitMQ integration events (asynchronous), never via shared DB schemas. - Enforcement: `docker-compose.yml` connection-string envs are scoped per service; PR review rejects cross-service `ConnectionStrings__` references. @@ -96,9 +96,9 @@ Stamp rotation is meaningless unless **every** JWT-accepting service enforces th - Evidence: `Services/TodoApi/Planora.Todo.Application/Features/Todos/HiddenTodoDtoFactory.cs`, ADR-0004. -**INV-AZ-4.** Todo comment threads require an accepted friendship between the viewer and the todo owner (when the todo is shared/public). Friendship check is mandatory in the comment handler. +**INV-AZ-4.** Task comment threads ("ветки") require an accepted friendship between the viewer and the task owner (when the task is shared/public). The comment timeline is owned by the Collaboration service, which never reads Todo's database: it authorises every comment read/write through the `TodoService.CheckTaskCommentAccess` gRPC call, which applies the exact owner / shared / public + friendship rule. The friendship check therefore remains mandatory and centralised in TodoApi (INV-OWN-2/3). -- Evidence: commit `5a3a83e` — "require friendship to read todo comments". +- Evidence: commit `5a3a83e` — "require friendship to read todo comments"; `Services/TodoApi/Planora.Todo.Api/Grpc/TodoGrpcService.cs` (`CheckTaskCommentAccess`); `Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/**`. **INV-AZ-5.** User-uploaded avatars are server-validated, re-encoded to WebP, and stripped of EXIF/ICC/XMP metadata before persistence. Raw bytes from `IFormFile` never reach disk. Only `image/jpeg`, `image/png`, `image/webp` are accepted, capped at 5 MB and 4096×4096; magic bytes are sniffed regardless of declared `Content-Type`. Storage is content-addressed under `/avatars/{userId}/{contentHash}/{size}.webp` and served with `Cache-Control: public, max-age=31536000, immutable`. diff --git a/tools/Planora.Migrator/CollaborationBackfill.cs b/tools/Planora.Migrator/CollaborationBackfill.cs new file mode 100644 index 00000000..c1637c57 --- /dev/null +++ b/tools/Planora.Migrator/CollaborationBackfill.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Planora.Migrator; + +/// +/// One-shot, idempotent backfill of the task comment timeline from the legacy +/// todo.todo_item_comments table (TodoApi) into collaboration.comments +/// (Collaboration service). Run AFTER both schemas exist and BEFORE flipping the +/// frontend to the Collaboration routes; safe to run twice (INSERT ... ON CONFLICT +/// (Id) DO NOTHING captures rows created in the cutover window). +/// +/// Usage: Planora.Migrator --backfill-collaboration +/// Connection strings: ConnectionStrings__TodoDatabase, ConnectionStrings__CollaborationDatabase. +/// +internal static class CollaborationBackfill +{ + private const int BatchSize = 500; + + public static async Task RunAsync( + string todoConnectionString, + string collaborationConnectionString, + ILogger logger, + CancellationToken cancellationToken = default) + { + await using var source = new NpgsqlConnection(todoConnectionString); + await using var target = new NpgsqlConnection(collaborationConnectionString); + await source.OpenAsync(cancellationToken); + await target.OpenAsync(cancellationToken); + + const string selectSql = """ + SELECT "Id", "TodoItemId", "AuthorId", "AuthorName", "Content", + "IsSystemComment", "IsGenesisComment", "CreatedAt", "CreatedBy", + "UpdatedAt", "UpdatedBy", "IsDeleted", "DeletedAt", "DeletedBy" + FROM todo.todo_item_comments + """; + + const string insertSql = """ + INSERT INTO collaboration.comments + ("Id", "TaskId", "AuthorId", "AuthorName", "Content", + "IsSystemComment", "IsGenesisComment", "CreatedAt", "CreatedBy", + "UpdatedAt", "UpdatedBy", "IsDeleted", "DeletedAt", "DeletedBy") + VALUES (@Id, @TaskId, @AuthorId, @AuthorName, @Content, + @IsSystemComment, @IsGenesisComment, @CreatedAt, @CreatedBy, + @UpdatedAt, @UpdatedBy, @IsDeleted, @DeletedAt, @DeletedBy) + ON CONFLICT ("Id") DO NOTHING + """; + + long read = 0, inserted = 0; + + await using var selectCmd = new NpgsqlCommand(selectSql, source); + await using var reader = await selectCmd.ExecuteReaderAsync(cancellationToken); + + while (await reader.ReadAsync(cancellationToken)) + { + read++; + await using var insert = new NpgsqlCommand(insertSql, target); + insert.Parameters.AddWithValue("Id", reader.GetGuid(0)); + insert.Parameters.AddWithValue("TaskId", reader.GetGuid(1)); + insert.Parameters.AddWithValue("AuthorId", reader.GetGuid(2)); + insert.Parameters.AddWithValue("AuthorName", reader.GetString(3)); + insert.Parameters.AddWithValue("Content", reader.GetString(4)); + insert.Parameters.AddWithValue("IsSystemComment", reader.GetBoolean(5)); + insert.Parameters.AddWithValue("IsGenesisComment", reader.GetBoolean(6)); + insert.Parameters.AddWithValue("CreatedAt", reader.GetDateTime(7)); + insert.Parameters.AddWithValue("CreatedBy", reader.IsDBNull(8) ? DBNull.Value : reader.GetGuid(8)); + insert.Parameters.AddWithValue("UpdatedAt", reader.IsDBNull(9) ? DBNull.Value : reader.GetDateTime(9)); + insert.Parameters.AddWithValue("UpdatedBy", reader.IsDBNull(10) ? DBNull.Value : reader.GetGuid(10)); + insert.Parameters.AddWithValue("IsDeleted", reader.GetBoolean(11)); + insert.Parameters.AddWithValue("DeletedAt", reader.IsDBNull(12) ? DBNull.Value : reader.GetDateTime(12)); + insert.Parameters.AddWithValue("DeletedBy", reader.IsDBNull(13) ? DBNull.Value : reader.GetGuid(13)); + + inserted += await insert.ExecuteNonQueryAsync(cancellationToken); + + if (read % BatchSize == 0) + { + logger.LogInformation("Backfill progress: {Read} read, {Inserted} inserted", read, inserted); + } + } + + logger.LogInformation( + "Collaboration backfill complete: {Read} source rows, {Inserted} inserted (duplicates skipped).", + read, inserted); + return true; + } +} diff --git a/tools/Planora.Migrator/Program.cs b/tools/Planora.Migrator/Program.cs index 8c22c31b..19292d88 100644 --- a/tools/Planora.Migrator/Program.cs +++ b/tools/Planora.Migrator/Program.cs @@ -71,7 +71,7 @@ public static async Task Main(string[] args) ? Services : Services.Where(s => parsed.Services.Contains(s.Name, StringComparer.OrdinalIgnoreCase)).ToList(); - if (selected.Count == 0) + if (selected.Count == 0 && !parsed.BackfillCollaboration) { logger.LogError("No matching services. Valid names: {Names}", string.Join(", ", Services.Select(s => s.Name))); return ExitBadArgs; @@ -98,6 +98,30 @@ public static async Task Main(string[] args) anyFailure |= !ok; } + if (parsed.BackfillCollaboration && !parsed.ListPendingOnly) + { + var todoConn = configuration.GetConnectionString("TodoDatabase"); + var collabConn = configuration.GetConnectionString("CollaborationDatabase"); + if (string.IsNullOrWhiteSpace(todoConn) || string.IsNullOrWhiteSpace(collabConn)) + { + logger.LogError( + "Backfill requires ConnectionStrings__TodoDatabase and ConnectionStrings__CollaborationDatabase."); + anyFailure = true; + } + else + { + try + { + await CollaborationBackfill.RunAsync(todoConn, collabConn, logger); + } + catch (Exception ex) + { + logger.LogError(ex, "Collaboration backfill failed."); + anyFailure = true; + } + } + } + overallStopwatch.Stop(); logger.LogInformation("Migrator finished in {Elapsed}. Outcome: {Outcome}", overallStopwatch.Elapsed, anyFailure ? "FAILED" : "OK"); @@ -188,6 +212,7 @@ private static void AddDbContext(Type contextType, IServiceCollection services, { var allServices = false; var listPending = false; + var backfillCollaboration = false; string? overrideConnStr = null; var services = new List(); @@ -201,6 +226,9 @@ private static void AddDbContext(Type contextType, IServiceCollection services, case "--list-pending": listPending = true; break; + case "--backfill-collaboration": + backfillCollaboration = true; + break; case "--service" when i + 1 < args.Length: services.Add(args[++i]); break; @@ -215,12 +243,12 @@ private static void AddDbContext(Type contextType, IServiceCollection services, } } - if (!allServices && services.Count == 0) + if (!allServices && services.Count == 0 && !backfillCollaboration) { return null; } - return new ParsedArgs(allServices, services, listPending, overrideConnStr); + return new ParsedArgs(allServices, services, listPending, backfillCollaboration, overrideConnStr); } private static void PrintUsage() @@ -233,15 +261,22 @@ private static void PrintUsage() Planora.Migrator --service [--service ...] Planora.Migrator --all --list-pending Planora.Migrator --service --connection-string "Host=..." + Planora.Migrator --backfill-collaboration SERVICES - auth, category, todo, messaging + auth, category, todo, messaging, collaboration CONFIG Connection strings: ConnectionStrings__AuthDatabase, ConnectionStrings__CategoryDatabase, - ConnectionStrings__TodoDatabase, ConnectionStrings__MessagingDatabase + ConnectionStrings__TodoDatabase, ConnectionStrings__MessagingDatabase, + ConnectionStrings__CollaborationDatabase (envvar or appsettings.json). Override per-run with --connection-string. + BACKFILL + --backfill-collaboration copies todo.todo_item_comments -> + collaboration.comments (idempotent). Needs ConnectionStrings__TodoDatabase + and ConnectionStrings__CollaborationDatabase. + EXIT CODES 0 success 64 bad arguments @@ -259,6 +294,7 @@ private sealed record ParsedArgs( bool AllServices, List Services, bool ListPendingOnly, + bool BackfillCollaboration, string? OverrideConnectionString); /// From 38cbe320edf7ca24d4a3ee11596e6d1f7076ece4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 05:12:51 +0000 Subject: [PATCH 04/14] polish(collaboration): 503 gRPC error mapping, Fly manifest, DB bootstrap - TaskAccessGrpcClient wraps gRPC faults in ExternalServiceUnavailableException (DomainException -> HTTP 503 via shared middleware), matching TodoApi semantics - Honour OperationCanceledException without remapping - deploy/fly/collaboration-service.fly.toml added (mirrors todo-service) - deploy/fly/postgres-init.sql creates planora_collaboration --- .../ExternalServiceUnavailableException.cs | 20 +++++++++++ .../Grpc/TaskAccessGrpcClient.cs | 17 ++++++--- deploy/fly/collaboration-service.fly.toml | 35 +++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs create mode 100644 deploy/fly/collaboration-service.fly.toml diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs new file mode 100644 index 00000000..b07ed70a --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs @@ -0,0 +1,20 @@ +using Planora.BuildingBlocks.Domain.Exceptions; + +namespace Planora.Collaboration.Application.Exceptions +{ + /// + /// Raised when a downstream service (Todo / Auth gRPC) is unavailable. Surfaces as + /// HTTP 503 via the shared global exception middleware (DomainException → status code), + /// mirroring TodoApi's identical contract for consistent cross-service error semantics. + /// + public sealed class ExternalServiceUnavailableException : DomainException + { + public ExternalServiceUnavailableException(string serviceName, string operation, Exception? innerException = null) + : base( + $"{serviceName} is currently unavailable. Operation: {operation}", + "EXTERNAL_SERVICE_UNAVAILABLE", + 503) + { + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs index 95e52147..0bd1fbe4 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs @@ -1,4 +1,5 @@ using Grpc.Core; +using Planora.Collaboration.Application.Exceptions; using Planora.Collaboration.Application.Services; using Planora.GrpcContracts; @@ -8,7 +9,8 @@ namespace Planora.Collaboration.Infrastructure.Grpc /// Authorises comment operations by delegating to TodoApi over gRPC. TodoApi owns the task /// aggregate and the ownership / sharing / public + friendship rules; this client never reads /// Todo's database (INV-OWN-1). The x-service-key header is injected by the shared - /// registered on the channel (INV-COMM-2). + /// + /// registered on the channel (INV-COMM-2). /// public sealed class TaskAccessGrpcClient : ITaskAccessService { @@ -48,16 +50,21 @@ public async Task CheckCommentAccessAsync( return new TaskAccessResult(response.Exists, response.HasAccess, ownerId, participants); } + catch (OperationCanceledException) + { + throw; + } catch (RpcException ex) { + // Fail closed: Todo could not authorise the request. Surface a clean 503 (via the + // shared DomainException → ProblemDetails mapping) instead of a raw gRPC fault. _logger.LogWarning( ex, - "Todo gRPC failed while checking comment access for task {TaskId}, requester {RequesterId}: Status={Status}", + "Todo gRPC unavailable while checking comment access for task {TaskId}, requester {RequesterId}: Status={Status}", taskId, requesterId, ex.StatusCode); - // Fail closed: no access decision available → deny. Treated as "exists, no access" - // so callers surface 403 rather than 404 when Todo is transiently unavailable. - throw; + throw new ExternalServiceUnavailableException("TodoApi", "CheckTaskCommentAccess", ex); } } } } + diff --git a/deploy/fly/collaboration-service.fly.toml b/deploy/fly/collaboration-service.fly.toml new file mode 100644 index 00000000..8cd03ac4 --- /dev/null +++ b/deploy/fly/collaboration-service.fly.toml @@ -0,0 +1,35 @@ +app = "planora-collaboration" +primary_region = "fra" + +[build] +dockerfile = "../../Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile" +ignorefile = "../../.dockerignore" + +[env] +ASPNETCORE_ENVIRONMENT = "Docker" +ASPNETCORE_URLS = "http://+:8080" +GrpcServices__AuthApi = "http://planora-auth.flycast:80" +GrpcServices__TodoApi = "http://planora-todo.flycast:80" +ServiceName = "collaboration-service" + +[http_service] +internal_port = 8080 +force_https = false +auto_stop_machines = true +auto_start_machines = true +min_machines_running = 0 +processes = ["app"] + +[[vm]] +size = "shared-cpu-1x" +memory = "512mb" + +[checks] +[checks.health] +grpc = false +method = "get" +path = "/health" +port = 8080 +interval = "15s" +timeout = "5s" +grace_period = "30s" From aa689d506eaa9bddd584d47c5f6502759c80218d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 05:16:12 +0000 Subject: [PATCH 05/14] fix(collaboration): correct error-exception ctor, Fly manifest, secret matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExternalServiceUnavailableException now mirrors TodoApi exactly (DomainException with ErrorCode.Infrastructure.ExternalServiceUnavailable + ServiceUnavailable category + inner exception) — the previous ctor signature did not exist - Rename deploy/fly manifest to collaboration.fly.toml matching the repo naming and structure (internal gRPC over :443, /health/live + /health/ready checks) - set-secrets.ps1: planora-collaboration joins the secret matrix (shared + DB + Auth/Todo gRPC addresses); migrator gains CollaborationDatabase --- .../ExternalServiceUnavailableException.cs | 19 ++++++---- deploy/fly/collaboration-service.fly.toml | 35 ------------------ deploy/fly/collaboration.fly.toml | 37 +++++++++++++++++++ deploy/fly/set-secrets.ps1 | 8 +++- 4 files changed, 56 insertions(+), 43 deletions(-) delete mode 100644 deploy/fly/collaboration-service.fly.toml create mode 100644 deploy/fly/collaboration.fly.toml diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs index b07ed70a..e8004054 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs @@ -3,18 +3,23 @@ namespace Planora.Collaboration.Application.Exceptions { /// - /// Raised when a downstream service (Todo / Auth gRPC) is unavailable. Surfaces as - /// HTTP 503 via the shared global exception middleware (DomainException → status code), - /// mirroring TodoApi's identical contract for consistent cross-service error semantics. + /// Raised when a downstream service (Todo / Auth gRPC) is unavailable. Surfaces as HTTP 503 + /// through the shared global exception middleware, mirroring TodoApi's identical contract for + /// consistent cross-service error semantics. /// public sealed class ExternalServiceUnavailableException : DomainException { - public ExternalServiceUnavailableException(string serviceName, string operation, Exception? innerException = null) + public override ErrorCategory Category => ErrorCategory.ServiceUnavailable; + + public ExternalServiceUnavailableException(string serviceName, string operationName, Exception innerException) : base( - $"{serviceName} is currently unavailable. Operation: {operation}", - "EXTERNAL_SERVICE_UNAVAILABLE", - 503) + $"{serviceName} is unavailable while executing {operationName}.", + Planora.BuildingBlocks.Domain.Exceptions.ErrorCode.Infrastructure.ExternalServiceUnavailable, + ErrorCategory.ServiceUnavailable, + innerException) { + AddDetail("ServiceName", serviceName); + AddDetail("OperationName", operationName); } } } diff --git a/deploy/fly/collaboration-service.fly.toml b/deploy/fly/collaboration-service.fly.toml deleted file mode 100644 index 8cd03ac4..00000000 --- a/deploy/fly/collaboration-service.fly.toml +++ /dev/null @@ -1,35 +0,0 @@ -app = "planora-collaboration" -primary_region = "fra" - -[build] -dockerfile = "../../Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile" -ignorefile = "../../.dockerignore" - -[env] -ASPNETCORE_ENVIRONMENT = "Docker" -ASPNETCORE_URLS = "http://+:8080" -GrpcServices__AuthApi = "http://planora-auth.flycast:80" -GrpcServices__TodoApi = "http://planora-todo.flycast:80" -ServiceName = "collaboration-service" - -[http_service] -internal_port = 8080 -force_https = false -auto_stop_machines = true -auto_start_machines = true -min_machines_running = 0 -processes = ["app"] - -[[vm]] -size = "shared-cpu-1x" -memory = "512mb" - -[checks] -[checks.health] -grpc = false -method = "get" -path = "/health" -port = 8080 -interval = "15s" -timeout = "5s" -grace_period = "30s" diff --git a/deploy/fly/collaboration.fly.toml b/deploy/fly/collaboration.fly.toml new file mode 100644 index 00000000..461892eb --- /dev/null +++ b/deploy/fly/collaboration.fly.toml @@ -0,0 +1,37 @@ +app = "planora-collaboration" +primary_region = "ams" + +[build] + dockerfile = "../../Services/CollaborationApi/Planora.Collaboration.Api/Dockerfile" + +[env] + ASPNETCORE_ENVIRONMENT = "Production" + ASPNETCORE_URLS = "http://+:8080" + GrpcServices__AuthApi = "https://planora-auth.internal:443" + GrpcServices__TodoApi = "https://planora-todo.internal:443" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [[http_service.checks]] + interval = "15s" + timeout = "5s" + grace_period = "30s" + method = "GET" + path = "/health/live" + + [[http_service.checks]] + interval = "15s" + timeout = "5s" + grace_period = "45s" + method = "GET" + path = "/health/ready" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" diff --git a/deploy/fly/set-secrets.ps1 b/deploy/fly/set-secrets.ps1 index b0e06347..a921526c 100644 --- a/deploy/fly/set-secrets.ps1 +++ b/deploy/fly/set-secrets.ps1 @@ -134,6 +134,11 @@ $matrix = [ordered]@{ 'ConnectionStrings__MessagingDatabase', 'GrpcServices__AuthApi' ) + 'planora-collaboration' = $shared + @( + 'ConnectionStrings__CollaborationDatabase', + 'GrpcServices__AuthApi', + 'GrpcServices__TodoApi' + ) 'planora-realtime' = $shared 'planora-gateway' = $shared + @('Frontend__BaseUrl') 'planora-outbox-worker' = $shared @@ -141,7 +146,8 @@ $matrix = [ordered]@{ 'ConnectionStrings__AuthDatabase', 'ConnectionStrings__CategoryDatabase', 'ConnectionStrings__TodoDatabase', - 'ConnectionStrings__MessagingDatabase' + 'ConnectionStrings__MessagingDatabase', + 'ConnectionStrings__CollaborationDatabase' ) } From 0c8490f6b8383c6cd59b805b05c0291ae9b1a157 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 05:17:21 +0000 Subject: [PATCH 06/14] fix(todo): remove duplicate using directive in DeleteTodoCommandHandler Duplicate 'using ...Application.Context;' would fail the -warnaserror build (CS0105). Verified no duplicate usings remain across Todo/Collaboration. --- .../Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs index 97543589..d989d66f 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs @@ -1,5 +1,4 @@ using Planora.BuildingBlocks.Application.Context; -using Planora.BuildingBlocks.Application.Context; using Planora.BuildingBlocks.Application.Messaging.Events; using Planora.BuildingBlocks.Application.Outbox; using Planora.BuildingBlocks.Domain; From a8b921ac05170ac4fca9f37756d2c4e6ff20220b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:42:08 +0000 Subject: [PATCH 07/14] fix(todo): force-track RemoveCommentsAddOutbox migration (gitignore bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo .gitignore lists **/Migrations/**; existing Todo migrations were force-added historically. 'git add -A' silently skipped the new migration, so the DROP of todo_item_comments + creation of todo.OutboxMessages was absent from history while the model snapshot already reflected them — a snapshot/DB drift that would make MigrateAsync fail or leave the schema wrong. Force-add both the migration and its designer so the schema change ships. --- ...120000_RemoveCommentsAddOutbox.Designer.cs | 324 ++++++++++++++++++ .../20260529120000_RemoveCommentsAddOutbox.cs | 107 ++++++ 2 files changed, 431 insertions(+) create mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.Designer.cs create mode 100644 Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.cs diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.Designer.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.Designer.cs new file mode 100644 index 00000000..2febe2ec --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.Designer.cs @@ -0,0 +1,324 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Planora.Todo.Infrastructure.Persistence; + +#nullable disable + +namespace Planora.Todo.Infrastructure.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20260529120000_RemoveCommentsAddOutbox")] + partial class RemoveCommentsAddOutbox + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("todo") + .HasAnnotation("ProductVersion", "9.0.15") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hidden") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("RequiredWorkers") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Todo"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("UserId", "Status", "IsDeleted", "CreatedAt") + .HasDatabaseName("ix_todo_items_user_status_deleted_created"); + + b.ToTable("TodoItems", "todo"); + }); + + modelBuilder.Entity("Planora.BuildingBlocks.Application.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NextRetryUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOnUtc"); + + b.HasIndex("Status", "OccurredOnUtc"); + + b.ToTable("OutboxMessages", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => + { + b.Property("TodoItemId") + .HasColumnType("uuid"); + + b.Property("SharedWithUserId") + .HasColumnType("uuid"); + + b.HasKey("TodoItemId", "SharedWithUserId"); + + b.HasIndex("SharedWithUserId"); + + b.ToTable("todo_item_shares", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemWorker", b => + { + b.Property("TodoItemId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("TodoItemId", "UserId"); + + b.HasIndex("TodoItemId"); + + b.HasIndex("UserId"); + + b.ToTable("todo_item_workers", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.UserTodoViewPreference", b => + { + b.Property("ViewerId") + .HasColumnType("uuid"); + + b.Property("TodoItemId") + .HasColumnType("uuid"); + + b.Property("CompletedByViewer") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("CompletedByViewerAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HiddenByViewer") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ViewerCategoryId") + .HasColumnType("uuid"); + + b.HasKey("ViewerId", "TodoItemId"); + + b.HasIndex("TodoItemId", "ViewerId"); + + b.ToTable("user_todo_view_preferences", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => + { + b.OwnsMany("Planora.Todo.Domain.Entities.TodoItemTag", "Tags", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b1.Property("TodoItemId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("TodoItemId"); + + b1.ToTable("todo_tags", "todo"); + + b1.WithOwner() + .HasForeignKey("TodoItemId"); + }); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => + { + b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) + .WithMany("SharedWith") + .HasForeignKey("TodoItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemWorker", b => + { + b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) + .WithMany("Workers") + .HasForeignKey("TodoItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => + { + b.Navigation("SharedWith"); + + b.Navigation("Workers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.cs new file mode 100644 index 00000000..e7f01649 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260529120000_RemoveCommentsAddOutbox.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Planora.Todo.Infrastructure.Migrations +{ + /// + public partial class RemoveCommentsAddOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // The task comment timeline ("ветки") moved to the Collaboration service. + // Drop the comment table here and introduce the outbox that publishes the + // task lifecycle integration events the Collaboration service consumes. + migrationBuilder.DropTable( + name: "todo_item_comments", + schema: "todo"); + + migrationBuilder.CreateTable( + name: "OutboxMessages", + schema: "todo", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Content = table.Column(type: "text", nullable: false), + OccurredOnUtc = table.Column(type: "timestamp with time zone", nullable: false), + ProcessedOnUtc = table.Column(type: "timestamp with time zone", nullable: true), + Status = table.Column(type: "text", nullable: false), + Error = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + RetryCount = table.Column(type: "integer", nullable: false, defaultValue: 0), + NextRetryUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "uuid", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessages_ProcessedOnUtc", + schema: "todo", + table: "OutboxMessages", + column: "ProcessedOnUtc"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessages_Status_OccurredOnUtc", + schema: "todo", + table: "OutboxMessages", + columns: new[] { "Status", "OccurredOnUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutboxMessages", + schema: "todo"); + + migrationBuilder.CreateTable( + name: "todo_item_comments", + schema: "todo", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TodoItemId = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + AuthorName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "character varying(5000)", maxLength: 5000, nullable: false), + IsSystemComment = table.Column(type: "boolean", nullable: false, defaultValue: false), + IsGenesisComment = table.Column(type: "boolean", nullable: false, defaultValue: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "uuid", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_todo_item_comments", x => x.Id); + table.ForeignKey( + name: "FK_todo_item_comments_TodoItems_TodoItemId", + column: x => x.TodoItemId, + principalSchema: "todo", + principalTable: "TodoItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_todo_item_comments_TodoItemId_CreatedAt", + schema: "todo", + table: "todo_item_comments", + columns: new[] { "TodoItemId", "CreatedAt" }); + } + } +} From 46703b465d7a754322e884e545448089079bb6b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:48:07 +0000 Subject: [PATCH 08/14] test+docs(collaboration): add handler/consumer tests; update database/API/architecture docs Tests (Collaboration): - CommentCommandHandlerTests: access matrix for add/genesis/update/delete (grant/deny/not-found, owner-only genesis, dup-genesis guard, author-vs-owner delete rules, notification fan-out count) - IntegrationEventConsumerTests: TaskCreated (system+genesis, replay-safe), TaskActivity (completed/started/left + unknown-type skip), TaskDeleted cascade, UserDeleted authored-comment cleanup + no-op Docs: - database.md: Collaboration DB section, ownership row, Todo OutboxMessages, RemoveCommentsAddOutbox migration, corrected gitignore/force-add note, backfill - API.md: Collaboration endpoint section + gateway routes; Todo comment routes removed - architecture.md: service list, boundaries, gRPC access delegation, event flow --- docs/API.md | 106 ++++---- docs/architecture.md | 16 +- docs/database.md | 71 ++++- .../Handlers/CommentCommandHandlerTests.cs | 247 ++++++++++++++++++ .../IntegrationEventConsumerTests.cs | 199 ++++++++++++++ 5 files changed, 586 insertions(+), 53 deletions(-) create mode 100644 tests/Planora.UnitTests/Services/CollaborationApi/Handlers/CommentCommandHandlerTests.cs create mode 100644 tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs diff --git a/docs/API.md b/docs/API.md index 98cf3c8a..80629b04 100644 --- a/docs/API.md +++ b/docs/API.md @@ -82,6 +82,7 @@ Ocelot route files leave most routes unthrottled, but the realtime route enables | `GET /todos/health` | Todo API | public | | `GET /categories/health` | Category API | public | | `GET /messaging/health` | Messaging API | public | +| `GET /collaboration/health` | Collaboration API | public | | `GET /realtime/health` | Realtime API | public | | `/auth/api/v1/auth/{everything}` | Auth `AuthenticationController` | mixed | | `/auth/api/v1/users/{everything}` | Auth `UsersController` | bearer at gateway; `VerifyEmailByToken` is `[AllowAnonymous]` in the service controller | @@ -91,6 +92,7 @@ Ocelot route files leave most routes unthrottled, but the realtime route enables | `/todos/api/v1/{everything}` | Todo API | bearer | | `/categories/api/v1/{everything}` | Category API | bearer | | `/messaging/api/v1/{everything}` | Messaging API | bearer | +| `/collaboration/api/v1/{everything}` | Collaboration API (task comment timeline) | bearer | | `/realtime/{everything}` | Realtime API, websocket route | route-dependent | ## Authentication @@ -461,10 +463,10 @@ All routes require bearer auth. | `PATCH` | `/{id}/viewer-preferences` | non-owner viewer hidden/category preference | | `POST` | `/{id}/join` | join task as a worker | | `POST` | `/{id}/leave` | leave task (stop being a worker) | -| `GET` | `/{id}/comments?pageNumber=1&pageSize=50` | get paginated comments (oldest-first) | -| `POST` | `/{id}/comments` | add a comment | -| `PUT` | `/{id}/comments/{commentId}` | edit own comment | -| `DELETE` | `/{id}/comments/{commentId}` | soft-delete comment (author or task owner) | + +> **Comments (the task timeline / "ветки") moved to the Collaboration service** — see the +> [Collaboration](#collaboration) section. The old `/{id}/comments*` and `/{id}/genesis` +> routes under `/todos/api/v1/todos` no longer exist. Create body: @@ -558,13 +560,33 @@ Success `204 No Content`. Errors: `400` for owner or non-worker; `404` if task not found. -### `GET /{id}/comments` +Hidden shared/public todos may return a redacted `TodoItemDto`; see [`features.md`](features.md#shared-todos-and-hidden-viewer-preferences). + +## Collaboration + +Gateway prefix: `/collaboration/api/v1/comments`. All routes require bearer auth. + +The Collaboration service owns the task **comment timeline** ("ветки"). It does not own tasks: +every route authorises against the task via the `TodoService.CheckTaskCommentAccess` gRPC call, +which applies the same owner / shared / public + friendship rule the Todo handlers used to. + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/{taskId}?pageNumber=1&pageSize=50` | get paginated comments (oldest-first) | +| `POST` | `/{taskId}` | add a comment | +| `POST` | `/{taskId}/genesis` | set the task description (genesis comment, owner only, one per task) | +| `PUT` | `/{taskId}/{commentId}` | edit a comment (author; owner for genesis) | +| `DELETE` | `/{taskId}/{commentId}` | soft-delete a comment (author or task owner) | + +### `GET /collaboration/api/v1/comments/{taskId}` -Get paginated comments for a task. Access requires task visibility (public/shared) and friendship with the owner. Default page size is 50. Comments are ordered oldest-first. +Get paginated comments for a task. Access requires task visibility (public/shared) and friendship +with the owner — enforced by the Todo gRPC access check. Default page size is 50, oldest-first. -Success `200`: `PagedResult`. +Success `200`: `PagedResult`. -`TodoCommentDto` shape: +`CommentDto` shape (wire-compatible with the former `TodoCommentDto` — the `todoItemId` field name +is kept so frontend timeline components are unchanged): ```json { @@ -572,62 +594,54 @@ Success `200`: `PagedResult`. "todoItemId": "00000000-0000-0000-0000-000000000000", "authorId": "00000000-0000-0000-0000-000000000000", "authorName": "Alice", + "authorAvatarUrl": null, "content": "Looks good!", "createdAt": "2026-05-10T14:00:00Z", "updatedAt": null, "isOwn": true, "isEdited": false, - "isSystemComment": false + "isSystemComment": false, + "isGenesisComment": false } ``` -`isOwn` is `true` when `authorId == currentUserId` AND `isSystemComment` is `false`. `isEdited` is `true` when `updatedAt > createdAt + 5 seconds` and `isSystemComment` is `false`. - -System comments (`isSystemComment: true`) are generated automatically by the backend for task lifecycle events. They have `authorId = Guid.Empty`, `authorName = ""`, `isOwn = false`, and `isEdited = false` always. The frontend renders them as a centered horizontal-rule divider with the event text, not as a standard chat bubble. System comments are never editable or deletable by users. - -Errors: `400` for unauthenticated; `403` for no access or non-friend; `404` if task not found. - -### `POST /{id}/comments` - -Add a comment. Only the task owner or an active worker can post. - -Body: - -```json -{ "content": "Great progress!" } -``` - -`content` required, max 2000 characters. - -Success `201 Created`: `TodoCommentDto`. - -Errors: `400` validation failure; `403` if not owner or active worker, or non-friend. +`isOwn` is `true` when `authorId == currentUserId` AND `isSystemComment` is `false`. `isEdited` is +`true` when `updatedAt > createdAt + 5 seconds` (genesis counts as editable; other system comments +never do). -### `PUT /{id}/comments/{commentId}` +System comments (`isSystemComment: true`) are materialised automatically from Todo task-lifecycle +integration events (created / completed / started / left). They have `authorId = Guid.Empty`, +`authorName = ""`, `isOwn = false`. The genesis comment is the task's initial description: it is a +system comment with `isGenesisComment: true` whose author is the task owner. Avatars are batch-fetched +live from Auth (`GetUserAvatarsBatch`, 60 s cache) — never stored on the comment row. -Edit a comment. Only the comment author can edit. +Errors: `400` unauthenticated; `403` no access / non-friend; `404` task not found; `503` if the Todo +access check is unavailable. -Body: +### `POST /collaboration/api/v1/comments/{taskId}` -```json -{ "content": "Updated text" } -``` +Add a comment. Caller must have task access. Body: `{ "content": "Great progress!" }` — required, max +2000 characters. Success `201 Created`: `CommentDto`. On success a `NotificationEvent` is fanned out +(via outbox → RabbitMQ → Realtime/SignalR) to every other participant. Errors: `400` validation; +`403` no access; `404` task not found. -`content` required, max 2000 characters. +### `POST /collaboration/api/v1/comments/{taskId}/genesis` -Success `200`: updated `TodoCommentDto`. +Set the task description as the genesis comment. **Owner only**, one per task. Body: +`{ "content": "..." }` — required, max 5000 characters. Success `201 Created`: `CommentDto`. Errors: +`400` validation or `GENESIS_ALREADY_EXISTS`; `403` not owner; `404` task not found. -Errors: `400` if comment not found or wrong todo scope; `403` if not the author. +### `PUT /collaboration/api/v1/comments/{taskId}/{commentId}` -### `DELETE /{id}/comments/{commentId}` +Edit a comment. Only the author may edit a regular comment; only the task owner may edit the genesis +comment. Body: `{ "content": "Updated text" }` — required, max 2000 characters (5000 for genesis). +Success `200`: updated `CommentDto`. Errors: `400` wrong task scope; `403` not author/owner; `404` +not found. -Soft-delete a comment. Allowed for the comment author or the task owner. +### `DELETE /collaboration/api/v1/comments/{taskId}/{commentId}` -Success `204 No Content`. - -Errors: `400` if comment not found or wrong todo scope; `403` if not author or task owner. - -Hidden shared/public todos may return a redacted `TodoItemDto`; see [`features.md`](features.md#shared-todos-and-hidden-viewer-preferences). +Soft-delete a comment. Allowed for the comment author or the task owner. Non-genesis system comments +cannot be deleted. Success `204 No Content`. Errors: `403` not allowed; `404` not found. ## Messaging diff --git a/docs/architecture.md b/docs/architecture.md index 2c96eb19..21de6cd6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,7 @@ flowchart LR | Todo API | todos, sharing, hidden state, viewer categories | `Services/TodoApi/Planora.Todo.Api/Program.cs`, `Controllers/TodosController.cs` | | Category API | category CRUD and category gRPC | `Services/CategoryApi/Planora.Category.Api/Program.cs` | | Messaging API | direct message HTTP/gRPC | `Services/MessagingApi/Planora.Messaging.Api/Program.cs` | +| Collaboration API | task comment timeline ("ветки"): user/genesis/system comments + comment notifications | `Services/CollaborationApi/Planora.Collaboration.Api/Program.cs`, `Controllers/CommentsController.cs` | | Realtime API | SignalR notification hub and notification controllers | `Services/RealtimeApi/Planora.Realtime.Api/Program.cs` | ## Service Boundaries @@ -55,9 +56,10 @@ flowchart LR | Service | Owns | Does not own | |---|---|---| | Auth | users, roles, user roles, refresh tokens, login history, password history, friendships, audit logs, auth outbox/inbox | todos, categories, messages | -| Todo | todo items, tags, todo shares, viewer preferences | user profiles, category definitions, friendship source of truth | +| Todo | todo items, tags, todo shares, viewer preferences, task-lifecycle outbox | user profiles, category definitions, friendship source of truth, comment timeline | | Category | categories | todo assignments beyond category id references | | Messaging | messages and messaging outbox/inbox | friendship ownership | +| Collaboration | task comment timeline (user/genesis/system comments), comment notifications outbox | task aggregate, task access rules (delegated to Todo via gRPC), friendship source of truth | | Realtime | SignalR connections, notification fan-out, Redis backplane | durable notification database | | Gateway | public route mapping and ingress concerns | domain rules | @@ -167,7 +169,8 @@ Confirmed cross-service checks: - Todo checks friendship through Auth before exposing public/direct-shared friend todos or accepting shared users. - Todo asks Category for category metadata and category ownership. -- Todo always batch-fetches current user avatar URLs from Auth (`GetUserAvatarsBatch` gRPC) when serving comment threads. The snapshot column on `TodoItemComment` was removed in migration `RemoveCommentAvatarSnapshot`; live enrichment is wrapped by `CachingUserService` (in-memory, 60 s TTL, 10 000-entry size cap) so paged comment reads stay cheap while bounding staleness after a user changes their avatar. +- Collaboration authorises every comment read/write through `TodoService.CheckTaskCommentAccess` (owner / shared / public + friendship), so it never reads Todo's database (INV-OWN-1) and never duplicates the sharing rules. +- Collaboration batch-fetches current user avatar URLs from Auth (`GetUserAvatarsBatch` gRPC) when serving comment threads. Live enrichment is wrapped by `CachingUserService` (in-memory, 60 s TTL) so paged comment reads stay cheap while bounding staleness after a user changes their avatar. - Messaging has Auth-related gRPC support in service configuration. ### Asynchronous RabbitMQ @@ -176,14 +179,21 @@ RabbitMQ contracts (`IEventBus`, `IIntegrationEventHandler`, `IntegrationEvent`, | Subscriber | Event | |---|---| -| Todo API | `CategoryDeletedIntegrationEvent`, `UserDeletedIntegrationEvent` | +| Todo API | `CategoryDeletedIntegrationEvent`, `UserDeletedIntegrationEvent`, `FriendshipRemovedIntegrationEvent` | | Category API | `UserDeletedIntegrationEvent` | +| Collaboration API | `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent`, `TaskDeletedIntegrationEvent`, `UserDeletedIntegrationEvent` | | Realtime API | `NotificationEvent` | +Publishers via outbox: + +- Todo publishes `TaskCreated` / `TaskActivity` / `TaskDeleted` on task lifecycle (create, complete/start/leave, delete) — these drive the Collaboration timeline instead of the old in-transaction comment writes. +- Collaboration publishes `NotificationEvent` per participant when a comment is added; Realtime delivers it over SignalR. + Code: - `Services/TodoApi/Planora.Todo.Api/Program.cs` - `Services/CategoryApi/Planora.Category.Api/Program.cs` +- `Services/CollaborationApi/Planora.Collaboration.Api/Program.cs` - `Services/RealtimeApi/Planora.Realtime.Api/Program.cs` - `BuildingBlocks/Planora.BuildingBlocks.Application/Messaging` (contracts + events) - `BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Messaging` (RabbitMQ implementation) diff --git a/docs/database.md b/docs/database.md index 0e500bb8..67737a34 100644 --- a/docs/database.md +++ b/docs/database.md @@ -16,6 +16,7 @@ Infrastructure: | Todo | `TodoDbContext` | `TodoDatabase` | `planora_todo` | | Category | `CategoryDbContext` | `CategoryDatabase` | `planora_category` | | Messaging | `MessagingDbContext` | `MessagingDatabase` | `planora_messaging` | +| Collaboration | `CollaborationDbContext` | `CollaborationDatabase` | `planora_collaboration` | | Realtime | none found | none found | not applicable | Code: @@ -119,8 +120,14 @@ Default schema: `todo` | `todo_tags` | owned collection for todo tags | | `todo_item_shares` | explicit shared-with users | | `todo_item_workers` | non-owner participants (workers) on public/shared tasks | -| `todo_item_comments` | comment thread on public/shared tasks; soft-deletable | | `user_todo_view_preferences` | viewer-specific hidden/category preferences | +| `OutboxMessages` | task-lifecycle integration events shipped to RabbitMQ (drives the Collaboration timeline) | + +> The comment thread ("ветки") no longer lives in the Todo database. It moved to the +> **Collaboration** service (`planora_collaboration.collaboration.comments`). Todo only +> publishes task-lifecycle facts (`TaskCreated` / `TaskActivity` / `TaskDeleted`) via its +> outbox; Collaboration consumes them and materialises system/genesis comments. See the +> **Collaboration Database** section below. ### Important Configuration @@ -130,8 +137,8 @@ Default schema: `todo` | `TodoTag` | owned table `todo_tags`, tag name max 50 | `TodoItemConfiguration.cs` | | `TodoItemShare` | table `todo_item_shares`, composite key `(TodoItemId, SharedWithUserId)`, index by shared user | `Persistence/Configurations/TodoItemShareConfiguration.cs` | | `TodoItemWorker` | table `todo_item_workers`, composite PK `(TodoItemId, UserId)`, `JoinedAt` default `now()`, cascade FK to `TodoItems`; indexes on `UserId` and `TodoItemId` | `Persistence/Configurations/TodoItemWorkerConfiguration.cs` | -| `TodoItemComment` | table `todo_item_comments`, PK `Id`, `AuthorId`, `AuthorName` max 200, `Content` max 5000, `IsSystemComment` bool (default false), `IsGenesisComment` bool (default false), soft delete, cascade FK to `TodoItems`; composite index `(TodoItemId, CreatedAt)`. Avatar URL is **not** stored — Todo always batch-fetches the current avatar from Auth via `GetUserAvatarsBatch` gRPC (60 s in-memory cache via `CachingUserService`). | `Persistence/Configurations/TodoItemCommentConfiguration.cs` | | `UserTodoViewPreference` | table `todo.user_todo_view_preferences`, composite key `(ViewerId, TodoItemId)`, `HiddenByViewer`, `CompletedByViewer` bool, `CompletedByViewerAt` nullable datetime, optional `ViewerCategoryId`, index `(TodoItemId, ViewerId)` | `Persistence/Configurations/UserTodoViewPreferenceConfiguration.cs` | +| `OutboxMessage` | table `todo.OutboxMessages`, status stored as string, indexes `(Status, OccurredOnUtc)` and `ProcessedOnUtc`; shipped by the shared `OutboxProcessor` | `Persistence/Configurations/OutboxMessageConfiguration.cs` | ### Worker Capacity Semantics @@ -141,7 +148,9 @@ When access changes (task made private, `SharedWith` list shrunk, or capacity re ### Todo Schema Bootstrap -Committed EF migrations are stored locally but not in the repository (the `**/Migrations/**` glob is `.gitignore`-listed). Runtime startup applies pending migrations automatically via `DatabaseStartup.EnsureReadyAsync`. Current local migrations: +The repository `.gitignore` lists `**/Migrations/**`, so migrations are **force-added** +(`git add -f`) when they must ship a schema change for review and CD. Runtime startup applies +pending migrations automatically via `DatabaseStartup.EnsureReadyAsync`. Current committed migrations: | Migration name | Date | Change | |---|---|---| @@ -150,6 +159,7 @@ Committed EF migrations are stored locally but not in the repository (the `**/Mi | `AddGenesisComment` | 2026-05-18 | Adds `is_genesis_comment bool NOT NULL DEFAULT false` to `todo_item_comments` | | `AddCommentAvatarUrl` | 2026-05-25 | Adds `AuthorAvatarUrl varchar(2048) NULL` to `todo_item_comments`; adds `xmin` row-version column to `TodoItems` for EF Core optimistic concurrency | | `RemoveCommentAvatarSnapshot` | 2026-05-26 | Drops `AuthorAvatarUrl` from `todo_item_comments`. Comment listing now always batch-fetches the live avatar from Auth via gRPC, cached in-memory 60 s. Single source of truth eliminates stale-avatar drift after the user changes their picture. | +| `RemoveCommentsAddOutbox` | 2026-05-29 | **Drops `todo_item_comments`** (the timeline moved to the Collaboration service) and creates `todo.OutboxMessages` so Todo can publish task-lifecycle integration events. Run the Collaboration backfill (`Planora.Migrator --backfill-collaboration`) **before** this migration is applied in production so no comment is lost. | To apply manually: @@ -216,6 +226,59 @@ Committed migrations are not stored in the repository. Messaging schema is deriv Runtime startup applies user-created migrations if they exist; otherwise it creates the schema from the current model. +## Collaboration Database + +DbContext: `Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/CollaborationDbContext.cs` + +Default schema: `collaboration` + +Owns the task **comment timeline** ("ветки") — regular user comments, the genesis comment +(the task's initial description), and auto-generated system comments (created / completed / +started / left). The service never reads the Todo database: it authorises every operation +through the `TodoService.CheckTaskCommentAccess` gRPC call (INV-OWN-1) and enriches avatars +through Auth's `GetUserAvatarsBatch` gRPC (60 s in-memory cache via `CachingUserService`). + +### Tables / DbSets + +| DbSet/table | Purpose | +|---|---| +| `comments` | unified timeline: user / genesis / system comments, soft-deletable | +| `OutboxMessages` | `NotificationEvent` fan-out to RabbitMQ (consumed by Realtime → SignalR) | + +### Important Configuration + +| Entity | Important fields/indexes | Code | +|---|---|---| +| `Comment` | PK `Id`, `TaskId` (value link to the Todo task — no FK, INV-OWN-1), `AuthorId`, `AuthorName` max 200, `Content` max 5000, `IsSystemComment`/`IsGenesisComment` bool (default false), soft delete, `xmin` optimistic concurrency; indexes `(TaskId, CreatedAt)` for timeline reads and `AuthorId` for the user-deletion cascade / moderation scans | `Persistence/Configurations/CommentConfiguration.cs` | +| `OutboxMessage` | table `collaboration.OutboxMessages`, status stored as string, indexes `(Status, OccurredOnUtc)` and `ProcessedOnUtc` | `Persistence/Configurations/OutboxMessageConfiguration.cs` | + +### Event Flow + +- **Inbound (Inbox):** subscribes to `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent`, + `TaskDeletedIntegrationEvent` (from Todo) and `UserDeletedIntegrationEvent` (from Auth). Handlers + are idempotent under replay (INV-COMM-4) — e.g. genesis is guarded by a uniqueness lookup. +- **Outbound (Outbox):** `AddComment` writes a `NotificationEvent` per participant (owner + workers + + shared-with, minus the author) so RealtimeApi can push a SignalR notification. + +### Collaboration Schema Bootstrap + +No committed EF migration: like Category, the schema is created on first run via +`DatabaseStartup.EnsureReadyAsync` → `EnsureCreatedAsync`. The database `planora_collaboration` +is auto-created at startup by `DependencyWaiter.WaitForPostgresWithDatabaseCreationAsync`. + +### Data Migration From Todo + +When extracting from an existing deployment, run the idempotent backfill **before** dropping the +old table: + +```bash +dotnet run --project tools/Planora.Migrator -- --backfill-collaboration +``` + +It copies `planora_todo.todo.todo_item_comments` → `planora_collaboration.collaboration.comments` +with `INSERT ... ON CONFLICT (Id) DO NOTHING`, so it is safe to run twice (once ahead of time, once +at cutover to capture the window). + ## Realtime Persistence No EF Core `DbContext`, migration folder, or database connection string was found for Realtime. Realtime connection state and notification delivery are implemented through service abstractions, SignalR, Redis backplane, and RabbitMQ subscriptions. @@ -232,7 +295,7 @@ Outbox and inbox primitives exist in shared infrastructure: - `BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox` - `BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Inbox` -Auth, Category, and Messaging explicitly expose outbox/inbox DbSets where needed. Todo consumes integration events for category and user deletion. +Auth, Category, Messaging, Todo, and Collaboration explicitly expose outbox/inbox DbSets where needed. Todo consumes integration events for category and user deletion, and publishes task-lifecycle events via its outbox. Collaboration consumes those task-lifecycle events plus user deletion, and publishes comment `NotificationEvent`s via its outbox. ## Safe Database Operations diff --git a/tests/Planora.UnitTests/Services/CollaborationApi/Handlers/CommentCommandHandlerTests.cs b/tests/Planora.UnitTests/Services/CollaborationApi/Handlers/CommentCommandHandlerTests.cs new file mode 100644 index 00000000..78bc477f --- /dev/null +++ b/tests/Planora.UnitTests/Services/CollaborationApi/Handlers/CommentCommandHandlerTests.cs @@ -0,0 +1,247 @@ +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.BuildingBlocks.Domain.Interfaces; +using Planora.Collaboration.Application.Features.Comments.Commands.AddComment; +using Planora.Collaboration.Application.Features.Comments.Commands.AddGenesisComment; +using Planora.Collaboration.Application.Features.Comments.Commands.DeleteComment; +using Planora.Collaboration.Application.Features.Comments.Commands.UpdateComment; +using Planora.Collaboration.Application.Services; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Repositories; +using Moq; + +namespace Planora.UnitTests.Services.CollaborationApi.Handlers; + +/// +/// Behavioural coverage for the comment command handlers. Mirrors the access matrix the +/// former TodoApi handlers enforced, now delegated to . +/// +public sealed class CommentCommandHandlerTests +{ + // ─── AddComment ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Functional")] + public async Task AddComment_WithAccess_PersistsAndFansOutNotifications() + { + var fixture = new Fixture(); + var owner = Guid.NewGuid(); + var author = fixture.UserId; + fixture.GrantAccess(owner, participants: new[] { owner, author, Guid.NewGuid() }); + + var result = await fixture.AddHandler().Handle( + new AddCommentCommand(fixture.TaskId, "Hello team"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Hello team", result.Value!.Content); + Assert.True(result.Value.IsOwn); + fixture.Comments.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + // Notification to every participant except the author (owner + 1 other = 2). + fixture.Outbox.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task AddComment_WithoutAccess_ThrowsForbidden() + { + var fixture = new Fixture(); + fixture.DenyAccess(); + + await Assert.ThrowsAsync(() => + fixture.AddHandler().Handle(new AddCommentCommand(fixture.TaskId, "x"), CancellationToken.None)); + + fixture.Comments.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task AddComment_OnMissingTask_ThrowsNotFound() + { + var fixture = new Fixture(); + fixture.TaskDoesNotExist(); + + await Assert.ThrowsAsync(() => + fixture.AddHandler().Handle(new AddCommentCommand(fixture.TaskId, "x"), CancellationToken.None)); + } + + // ─── AddGenesisComment ────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Functional")] + public async Task AddGenesis_AsOwner_WhenNoneExists_Persists() + { + var fixture = new Fixture(); + fixture.GrantAccess(owner: fixture.UserId); + fixture.Comments + .Setup(x => x.GetGenesisCommentAsync(fixture.TaskId, It.IsAny())) + .ReturnsAsync((Comment?)null); + + var result = await fixture.GenesisHandler().Handle( + new AddGenesisCommentCommand(fixture.TaskId, "The description"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value!.IsGenesisComment); + fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task AddGenesis_AsNonOwner_ThrowsForbidden() + { + var fixture = new Fixture(); + fixture.GrantAccess(owner: Guid.NewGuid()); // someone else owns the task + + await Assert.ThrowsAsync(() => + fixture.GenesisHandler().Handle(new AddGenesisCommentCommand(fixture.TaskId, "x"), CancellationToken.None)); + } + + [Fact] + [Trait("TestType", "Regression")] + public async Task AddGenesis_WhenAlreadyExists_FailsWithoutDuplicate() + { + var fixture = new Fixture(); + fixture.GrantAccess(owner: fixture.UserId); + fixture.Comments + .Setup(x => x.GetGenesisCommentAsync(fixture.TaskId, It.IsAny())) + .ReturnsAsync(Comment.CreateGenesis(fixture.TaskId, "existing", "Owner")); + + var result = await fixture.GenesisHandler().Handle( + new AddGenesisCommentCommand(fixture.TaskId, "second"), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal("GENESIS_ALREADY_EXISTS", result.Error!.Code); + fixture.Comments.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ─── DeleteComment ────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Security")] + public async Task DeleteComment_SystemComment_ThrowsForbidden() + { + var fixture = new Fixture(); + var system = Comment.CreateSystem(fixture.TaskId, "X created the task"); + fixture.Comments.Setup(x => x.GetByIdAsync(system.Id, It.IsAny())).ReturnsAsync(system); + + await Assert.ThrowsAsync(() => + fixture.DeleteHandler().Handle(new DeleteCommentCommand(fixture.TaskId, system.Id), CancellationToken.None)); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task DeleteComment_ByAuthor_SoftDeletes() + { + var fixture = new Fixture(); + var comment = Comment.Create(fixture.TaskId, fixture.UserId, "Me", "mine"); + fixture.Comments.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())).ReturnsAsync(comment); + fixture.GrantAccess(owner: Guid.NewGuid()); + + var result = await fixture.DeleteHandler().Handle( + new DeleteCommentCommand(fixture.TaskId, comment.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(comment.IsDeleted); + fixture.Comments.Verify(x => x.Update(comment), Times.Once); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task DeleteComment_ByStranger_ThrowsForbidden() + { + var fixture = new Fixture(); + var comment = Comment.Create(fixture.TaskId, Guid.NewGuid(), "Other", "theirs"); + fixture.Comments.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())).ReturnsAsync(comment); + fixture.GrantAccess(owner: Guid.NewGuid()); // viewer is neither author nor owner + + await Assert.ThrowsAsync(() => + fixture.DeleteHandler().Handle(new DeleteCommentCommand(fixture.TaskId, comment.Id), CancellationToken.None)); + } + + // ─── UpdateComment ────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Functional")] + public async Task UpdateComment_ByAuthor_Updates() + { + var fixture = new Fixture(); + var comment = Comment.Create(fixture.TaskId, fixture.UserId, "Me", "old"); + fixture.Comments.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())).ReturnsAsync(comment); + fixture.Users + .Setup(x => x.GetUserAvatarsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var result = await fixture.UpdateHandler().Handle( + new UpdateCommentCommand(fixture.TaskId, comment.Id, "new content"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("new content", comment.Content); + fixture.Comments.Verify(x => x.Update(comment), Times.Once); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task UpdateComment_WrongTask_ThrowsNotFound() + { + var fixture = new Fixture(); + var comment = Comment.Create(Guid.NewGuid(), fixture.UserId, "Me", "old"); + fixture.Comments.Setup(x => x.GetByIdAsync(comment.Id, It.IsAny())).ReturnsAsync(comment); + + await Assert.ThrowsAsync(() => + fixture.UpdateHandler().Handle( + new UpdateCommentCommand(fixture.TaskId, comment.Id, "x"), CancellationToken.None)); + } + + // ─── Fixture ──────────────────────────────────────────────────────────────── + + private sealed class Fixture + { + public Guid TaskId { get; } = Guid.NewGuid(); + public Guid UserId { get; } = Guid.NewGuid(); + public Mock Comments { get; } = new(); + public Mock UnitOfWork { get; } = new(); + public Mock CurrentUser { get; } = new(); + public Mock Access { get; } = new(); + public Mock Outbox { get; } = new(); + public Mock Users { get; } = new(); + + public Fixture() + { + CurrentUser.SetupGet(x => x.UserId).Returns(UserId); + CurrentUser.SetupGet(x => x.Name).Returns("Tester"); + CurrentUser.SetupGet(x => x.Email).Returns("tester@planora.dev"); + CurrentUser.SetupGet(x => x.ProfilePictureUrl).Returns((string?)null); + UnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); + Comments.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Comment c, CancellationToken _) => c); + Outbox.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + public void GrantAccess(Guid owner, IReadOnlyList? participants = null) => + Access.Setup(x => x.CheckCommentAccessAsync(TaskId, UserId, It.IsAny())) + .ReturnsAsync(new TaskAccessResult(true, true, owner, participants ?? new[] { owner })); + + public void DenyAccess() => + Access.Setup(x => x.CheckCommentAccessAsync(TaskId, UserId, It.IsAny())) + .ReturnsAsync(new TaskAccessResult(true, false, Guid.NewGuid(), Array.Empty())); + + public void TaskDoesNotExist() => + Access.Setup(x => x.CheckCommentAccessAsync(TaskId, UserId, It.IsAny())) + .ReturnsAsync(new TaskAccessResult(false, false, Guid.Empty, Array.Empty())); + + public AddCommentCommandHandler AddHandler() => + new(Comments.Object, UnitOfWork.Object, CurrentUser.Object, Access.Object, Outbox.Object); + + public AddGenesisCommentCommandHandler GenesisHandler() => + new(Comments.Object, UnitOfWork.Object, CurrentUser.Object, Access.Object); + + public DeleteCommentCommandHandler DeleteHandler() => + new(Comments.Object, UnitOfWork.Object, CurrentUser.Object, Access.Object); + + public UpdateCommentCommandHandler UpdateHandler() => + new(Comments.Object, UnitOfWork.Object, CurrentUser.Object, Access.Object, Users.Object); + } +} diff --git a/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs b/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs new file mode 100644 index 00000000..79859d69 --- /dev/null +++ b/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs @@ -0,0 +1,199 @@ +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Domain.Interfaces; +using Planora.Collaboration.Application.Features.IntegrationEvents; +using Planora.Collaboration.Domain.Entities; +using Planora.Collaboration.Domain.Repositories; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Planora.UnitTests.Services.CollaborationApi.IntegrationEvents; + +/// +/// Covers the Inbox side: task-lifecycle integration events from TodoApi are +/// materialised into the timeline. Replays must be idempotent (INV-COMM-4). +/// +public sealed class IntegrationEventConsumerTests +{ + // ─── TaskCreated ──────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Integration")] + public async Task TaskCreated_WithDescription_WritesSystemAndGenesisComments() + { + var taskId = Guid.NewGuid(); + var owner = Guid.NewGuid(); + var comments = new Mock(); + var uow = new Mock(); + var added = new List(); + comments.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((c, _) => added.Add(c)) + .ReturnsAsync((Comment c, CancellationToken _) => c); + comments.Setup(x => x.GetGenesisCommentAsync(taskId, It.IsAny())) + .ReturnsAsync((Comment?)null); + + var consumer = new TaskCreatedEventConsumer(comments.Object, uow.Object, + Mock.Of>()); + + await consumer.HandleAsync( + new TaskCreatedIntegrationEvent(taskId, owner, "Alice", "My description"), CancellationToken.None); + + Assert.Equal(2, added.Count); + Assert.Contains(added, c => c.IsSystemComment && !c.IsGenesisComment && c.Content.Contains("created the task")); + Assert.Contains(added, c => c.IsGenesisComment && c.Content == "My description"); + uow.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task TaskCreated_WithoutDescription_WritesOnlySystemComment() + { + var taskId = Guid.NewGuid(); + var comments = new Mock(); + var added = new List(); + comments.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((c, _) => added.Add(c)) + .ReturnsAsync((Comment c, CancellationToken _) => c); + + var consumer = new TaskCreatedEventConsumer(comments.Object, Mock.Of(), + Mock.Of>()); + + await consumer.HandleAsync( + new TaskCreatedIntegrationEvent(taskId, Guid.NewGuid(), "Bob", null), CancellationToken.None); + + Assert.Single(added); + Assert.True(added[0].IsSystemComment); + Assert.False(added[0].IsGenesisComment); + comments.Verify(x => x.GetGenesisCommentAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + [Trait("TestType", "Regression")] + public async Task TaskCreated_Replay_DoesNotDuplicateGenesis() + { + var taskId = Guid.NewGuid(); + var comments = new Mock(); + var added = new List(); + comments.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((c, _) => added.Add(c)) + .ReturnsAsync((Comment c, CancellationToken _) => c); + // Genesis already exists from a prior delivery. + comments.Setup(x => x.GetGenesisCommentAsync(taskId, It.IsAny())) + .ReturnsAsync(Comment.CreateGenesis(taskId, "existing", "Alice")); + + var consumer = new TaskCreatedEventConsumer(comments.Object, Mock.Of(), + Mock.Of>()); + + await consumer.HandleAsync( + new TaskCreatedIntegrationEvent(taskId, Guid.NewGuid(), "Alice", "desc"), CancellationToken.None); + + // Only the system comment is added; genesis is NOT re-created. + Assert.Single(added); + Assert.True(added[0].IsSystemComment && !added[0].IsGenesisComment); + } + + // ─── TaskActivity ─────────────────────────────────────────────────────────── + + [Theory] + [Trait("TestType", "Functional")] + [InlineData(TaskActivityType.Completed, "completed the task")] + [InlineData(TaskActivityType.StartedWorking, "started working on the task")] + [InlineData(TaskActivityType.Left, "left the task")] + public async Task TaskActivity_WritesExpectedSystemComment(string activity, string expectedFragment) + { + var taskId = Guid.NewGuid(); + var comments = new Mock(); + Comment? captured = null; + comments.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((c, _) => captured = c) + .ReturnsAsync((Comment c, CancellationToken _) => c); + + var consumer = new TaskActivityEventConsumer(comments.Object, Mock.Of(), + Mock.Of>()); + + await consumer.HandleAsync( + new TaskActivityIntegrationEvent(taskId, Guid.NewGuid(), "Carol", activity), CancellationToken.None); + + Assert.NotNull(captured); + Assert.True(captured!.IsSystemComment); + Assert.Contains(expectedFragment, captured.Content); + Assert.Contains("Carol", captured.Content); + } + + [Fact] + [Trait("TestType", "Resilience")] + public async Task TaskActivity_UnknownType_IsSkippedSilently() + { + var comments = new Mock(); + var consumer = new TaskActivityEventConsumer(comments.Object, Mock.Of(), + Mock.Of>()); + + await consumer.HandleAsync( + new TaskActivityIntegrationEvent(Guid.NewGuid(), Guid.NewGuid(), "X", "BogusType"), CancellationToken.None); + + comments.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ─── TaskDeleted ──────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Integration")] + public async Task TaskDeleted_CascadeSoftDeletesTimeline() + { + var taskId = Guid.NewGuid(); + var actor = Guid.NewGuid(); + var comments = new Mock(); + var uow = new Mock(); + + var consumer = new TaskDeletedEventConsumer(comments.Object, uow.Object, + Mock.Of>()); + + await consumer.HandleAsync(new TaskDeletedIntegrationEvent(taskId, actor), CancellationToken.None); + + comments.Verify(x => x.SoftDeleteByTaskIdAsync(taskId, actor, It.IsAny()), Times.Once); + uow.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + // ─── UserDeleted ──────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Integration")] + public async Task UserDeleted_SoftDeletesAuthoredComments() + { + var userId = Guid.NewGuid(); + var taskId = Guid.NewGuid(); + var authored = new List + { + Comment.Create(taskId, userId, "U", "one"), + Comment.Create(taskId, userId, "U", "two"), + }; + var comments = new Mock(); + comments.Setup(x => x.FindAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(authored); + var uow = new Mock(); + + var consumer = new UserDeletedEventConsumer(comments.Object, uow.Object, + Mock.Of>()); + + await consumer.HandleAsync(new UserDeletedIntegrationEvent(userId, "u@planora.dev"), CancellationToken.None); + + Assert.All(authored, c => Assert.True(c.IsDeleted)); + uow.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task UserDeleted_NoComments_IsNoOp() + { + var comments = new Mock(); + comments.Setup(x => x.FindAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(new List()); + var uow = new Mock(); + + var consumer = new UserDeletedEventConsumer(comments.Object, uow.Object, + Mock.Of>()); + + await consumer.HandleAsync(new UserDeletedIntegrationEvent(Guid.NewGuid(), "u@planora.dev"), CancellationToken.None); + + uow.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); + } +} From 3e30c163e182608c615be1322191bfbd7a4d45f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:52:47 +0000 Subject: [PATCH 09/14] docs+test: complete Collaboration docs sweep + restore worker-lifecycle coverage Docs (all service-facing pages now reflect the Collaboration extraction): - codebase-map.md: Collaboration Service section; Todo critical-files updated - features.md: Task Comments rewritten around the Collaboration service + event flow - security-idor-coverage.md: comment rows repointed to /collaboration + new coverage, worker rows repointed off the deleted test file - testing.md: Collaboration test inventory; worker lifecycle now event-based - overview.md / index.md / glossary.md: Collaboration listed in services, ownership, terms Tests: - WorkerLifecycleEventTests.cs: pins that Join/Leave publish the correct TaskActivityIntegrationEvent via outbox (restores coverage lost when the comment-coupled WorkersAndComments test suite was removed), owner-cannot-leave guard --- docs/codebase-map.md | 42 ++++-- docs/features.md | 85 +++++++----- docs/glossary.md | 5 + docs/index.md | 1 + docs/overview.md | 4 +- docs/security-idor-coverage.md | 26 ++-- docs/testing.md | 10 +- .../Handlers/WorkerLifecycleEventTests.cs | 127 ++++++++++++++++++ 8 files changed, 247 insertions(+), 53 deletions(-) create mode 100644 tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs diff --git a/docs/codebase-map.md b/docs/codebase-map.md index e3904fda..dbdb548e 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -94,26 +94,48 @@ Critical files: - `Controllers/TodosController.cs` - `Features/Todos/Queries/GetUserTodos/GetUserTodosQueryHandler.cs` - `Features/Todos/Queries/GetTodoById/GetTodoByIdQueryHandler.cs` -- `Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs` -- `Features/Todos/Commands/CreateTodo/CreateTodoCommandHandler.cs` -- `Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs` +- `Features/Todos/Commands/CreateTodo/CreateTodoCommandHandler.cs` — publishes `TaskCreatedIntegrationEvent` +- `Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs` — publishes `TaskActivityIntegrationEvent` - `Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs` - `Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs` -- `Features/Todos/Commands/AddComment/AddCommentCommandHandler.cs` -- `Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs` -- `Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs` +- `Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs` — publishes `TaskDeletedIntegrationEvent` - `Features/Todos/Commands/SetTodoHidden/SetTodoHiddenCommandHandler.cs` - `Features/Todos/Commands/SetViewerPreference/SetViewerPreferenceCommandHandler.cs` +- `Features/Todos/Common/OutboxExtensions.cs` — helper to enqueue integration events in the unit of work - `Features/Todos/TodoViewerStateResolver.cs` - `Features/Todos/HiddenTodoDtoFactory.cs` +- `Api/Grpc/TodoGrpcService.cs` — includes `CheckTaskCommentAccess` (authorises Collaboration) - `Domain/Entities/TodoItem.cs` — aggregate root with `Workers`, `RequiredWorkers`, `IsCapacityFull` - `Domain/Entities/TodoItemWorker.cs` -- `Domain/Entities/TodoItemComment.cs` -- `Domain/Repositories/ITodoCommentRepository.cs` - `Persistence/TodoDbContext.cs` -- `Persistence/Repositories/TodoCommentRepository.cs` - `Persistence/Configurations/TodoItemWorkerConfiguration.cs` -- `Persistence/Configurations/TodoItemCommentConfiguration.cs` +- `Persistence/Configurations/OutboxMessageConfiguration.cs` + +> The comment timeline ("ветки") is no longer in Todo — it lives in the **Collaboration Service** below. + +## Collaboration Service + +| Path | Purpose | +|---|---| +| `Services/CollaborationApi/Planora.Collaboration.Api` | comment HTTP controller, startup, integration-event subscriptions | +| `Services/CollaborationApi/Planora.Collaboration.Application` | comment commands/queries/validators, DTO, Inbox consumers, service ports | +| `Services/CollaborationApi/Planora.Collaboration.Domain` | `Comment` aggregate, domain event, repository contract | +| `Services/CollaborationApi/Planora.Collaboration.Infrastructure` | EF Core context/configuration/repository, Todo+Auth gRPC clients, outbox | + +Critical files: + +- `Api/Controllers/CommentsController.cs` — `/api/v1/comments` (get/add/genesis/update/delete) +- `Api/Program.cs` — subscribes to `TaskCreated`/`TaskActivity`/`TaskDeleted`/`UserDeleted` +- `Application/Features/Comments/Commands/*` — handlers + FluentValidation validators +- `Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs` +- `Application/Features/IntegrationEvents/*EventConsumer.cs` — Inbox materialisation (idempotent) +- `Application/Services/ITaskAccessService.cs` / `IUserService.cs` — outward ports +- `Domain/Entities/Comment.cs` — `Create` / `CreateSystem` / `CreateGenesis` / `Update*` +- `Domain/Repositories/ICommentRepository.cs` +- `Infrastructure/Grpc/TaskAccessGrpcClient.cs` — wraps `TodoService.CheckTaskCommentAccess` +- `Infrastructure/Grpc/{UserGrpcService,CachingUserService}.cs` — avatar enrichment (60 s cache) +- `Infrastructure/Persistence/CollaborationDbContext.cs` +- `Infrastructure/Persistence/Configurations/CommentConfiguration.cs` ## Category Service diff --git a/docs/features.md b/docs/features.md index a0e50329..7b1bf50a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -234,53 +234,72 @@ Allow friends to claim participation slots on public or shared tasks. The task o - `WorkerJoinButton` renders nothing for the owner; shows "Join" (indigo) for eligible friends; shows disabled "Full" with a lock icon when at capacity; shows "Leave" (outlined) when already working. - `onJoin` / `onLeave` callbacks on `TodoCard` optimistically update `isWorking` and `workerCount` in local state. -## Task Comments +## Task Comments (Collaboration Service) ### Purpose -Provide a persistent discussion thread on public and shared tasks. Only the owner and active workers can post; anyone with task access can read. +Provide a persistent discussion timeline ("ветки") on public and shared tasks: user comments, the +genesis comment (the task's description), and auto-generated system comments for lifecycle events. +The timeline is owned by the dedicated **Collaboration** service, decoupled from the Todo aggregate. + +### Architecture + +The Collaboration service owns the comment data (`planora_collaboration.collaboration.comments`) and +never reads the Todo database. Two integration boundaries keep it consistent (INV-OWN-1): + +- **Authorisation (synchronous gRPC):** every read/write calls `TodoService.CheckTaskCommentAccess`, + which returns `exists`, `hasAccess` (owner / shared / public + friendship), `ownerId`, and + `participantIds`. Collaboration applies no sharing rules of its own. +- **System/genesis comments (asynchronous, Outbox→Inbox):** Todo publishes task-lifecycle events; the + Collaboration Inbox consumers materialise the corresponding system/genesis comments. This replaces + the former in-transaction comment writes inside the Todo handlers. ### Implementation -- `Services/TodoApi/Planora.Todo.Domain/Entities/TodoItemComment.cs` -- `Services/TodoApi/Planora.Todo.Domain/Events/TodoCommentAddedDomainEvent.cs` -- `Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoCommentRepository.cs` -- `Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoCommentRepository.cs` -- `Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/` -- `Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/` -- `Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/` -- `Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/` -- `frontend/src/components/todos/task-comments.tsx` +- `Services/CollaborationApi/Planora.Collaboration.Domain/Entities/Comment.cs` +- `Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs` +- `Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/{AddComment,AddGenesisComment,UpdateComment,DeleteComment}/` (handler + FluentValidation validator) +- `Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/` +- `Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/` (Inbox consumers) +- `Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/{TaskAccessGrpcClient,UserGrpcService,CachingUserService}.cs` +- `Services/TodoApi/Planora.Todo.Api/Grpc/TodoGrpcService.cs` — `CheckTaskCommentAccess` +- `frontend/src/components/todos/task-comments.tsx` (calls `/collaboration/api/v1/comments/*`) ### Key Rules | Rule | Detail | |---|---| -| Read access | owner, any user with task access (public/shared) who is also friends with the owner | -| Write access | owner or active worker (in `todo_item_workers`) | -| Content limits | max 2000 characters; cannot be empty | -| `AuthorName` | denormalized at write time from JWT `name` or `given_name` claim, falling back to email then userId | -| Edit rules | only the comment author can edit their own comment | -| Delete rules | comment author OR task owner can delete; results in soft delete | -| `IsEdited` | true when `UpdatedAt > CreatedAt + 5 seconds`; always false for system comments | -| `UpdatedAt` on create | not set on creation; only set when content is actually updated via `UpdateContent` | -| Cascade delete | todo soft-delete also soft-deletes all comments via `SoftDeleteByTodoIdAsync` | -| Pagination | `GET {id}/comments` accepts `pageNumber` (default 1) and `pageSize` (default 50); sorted oldest-first | +| Read access | any user the Todo access check returns `hasAccess` for (owner, or friend with public/shared visibility) | +| Write access | same `hasAccess` rule; the access decision is owned by Todo, not duplicated here | +| Content limits | comment max 2000 chars; genesis max 5000 chars; cannot be empty (FluentValidation + domain) | +| `AuthorName` | denormalized at write time from JWT `name`/`email`/userId | +| Edit rules | comment author edits their own comment; only the task owner edits the genesis comment | +| Delete rules | comment author OR task owner; soft delete; non-genesis system comments cannot be deleted | +| `IsEdited` | true when `UpdatedAt > CreatedAt + 5 seconds`; genesis counts, other system comments never | +| Cascade delete | task delete emits `TaskDeletedIntegrationEvent`; the consumer soft-deletes the timeline | +| User delete | `UserDeletedIntegrationEvent` soft-deletes all comments authored by that user | +| Notifications | adding a comment enqueues a `NotificationEvent` per other participant (Outbox → Realtime/SignalR) | +| Pagination | `GET /comments/{taskId}` accepts `pageNumber` (default 1) and `pageSize` (default 50); oldest-first | ### System Comments -System comments are auto-generated by the backend on task lifecycle events. They are never authored by a user — `AuthorId = Guid.Empty`, `AuthorName = ""`, `isOwn = false`, `isEdited = false`, and `isSystemComment = true` in every response. Users cannot create, edit, or delete system comments. - -| Event | System comment text | Handler | -|---|---|---| -| Task created | `"{name} created the task"` | `CreateTodoCommandHandler` | -| Owner sets status → InProgress | `"{name} started working on the task"` | `UpdateTodoCommandHandler` | -| Owner sets status → Todo (from InProgress) | `"{name} left the task"` | `UpdateTodoCommandHandler` | -| Non-owner worker joined | `"{name} started working on the task"` | `JoinTodoCommandHandler` | -| Non-owner worker left | `"{name} left the task"` | `LeaveTodoCommandHandler` | -| Task completed (owner or viewer) | `"{name} completed the task"` | `UpdateTodoCommandHandler` | - -`{name}` is resolved from `ICurrentUserContext.Name`, then `Email`, then `UserId.ToString()` as a fallback. +System comments are materialised by the Collaboration Inbox consumers from Todo task-lifecycle +integration events. They are never authored by a user — `AuthorId = Guid.Empty`, `AuthorName = ""`, +`isOwn = false`, `isSystemComment = true`. The genesis comment is a system comment with +`isGenesisComment = true` whose effective author is the task owner. + +| Todo trigger | Integration event | System comment text | Collaboration consumer | +|---|---|---|---| +| Task created | `TaskCreatedIntegrationEvent` | `"{name} created the task"` (+ genesis from description) | `TaskCreatedEventConsumer` | +| Owner → InProgress | `TaskActivityIntegrationEvent (StartedWorking)` | `"{name} started working on the task"` | `TaskActivityEventConsumer` | +| Owner → Todo (from InProgress) | `TaskActivityIntegrationEvent (Left)` | `"{name} left the task"` | `TaskActivityEventConsumer` | +| Worker joined | `TaskWorkerJoined`→`TaskActivity (StartedWorking)` | `"{name} started working on the task"` | `TaskActivityEventConsumer` | +| Worker left | `TaskActivityIntegrationEvent (Left)` | `"{name} left the task"` | `TaskActivityEventConsumer` | +| Task completed | `TaskActivityIntegrationEvent (Completed)` | `"{name} completed the task"` | `TaskActivityEventConsumer` | + +`{name}` is captured in the event by Todo (from `ICurrentUserContext.Name` → `Email` → `UserId`), +so Collaboration needs no extra lookup to render the sentence. Consumers are idempotent under replay +(INV-COMM-4) — e.g. genesis creation is guarded by a uniqueness check. ### Frontend Behavior diff --git a/docs/glossary.md b/docs/glossary.md index e3a367b8..bd65bb56 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -8,6 +8,11 @@ | CSRF | Cross-site request forgery protection using double-submit cookie/header | `CsrfProtectionMiddleware.cs`, `frontend/src/lib/csrf.ts` | | Category | User-owned label for todos with color/icon/order | `Services/CategoryApi` | | Category gRPC | Internal service contract used to validate/enrich todo categories | `GrpcContracts/Protos/category.proto` | +| Collaboration API | Service that owns the task comment timeline ("ветки") and comment notifications; authorises every operation against Todo via gRPC | `Services/CollaborationApi` | +| Comment timeline ("ветки") | Per-task chronological thread of user, genesis, and system comments | `Services/CollaborationApi/.../Comment.cs` | +| Genesis comment | The task's initial description rendered as the first timeline entry; system comment owned by the task owner, one per task | `Comment.CreateGenesis` | +| System comment | Auto-generated timeline entry for task lifecycle (created/completed/started/left); no user author | `Comment.CreateSystem`, Collaboration Inbox consumers | +| CheckTaskCommentAccess | Todo gRPC call returning task existence, comment access, owner, and participants for Collaboration | `GrpcContracts/Protos/todo.proto`, `TodoGrpcService.cs` | | CQRS | Command/query separation through MediatR handlers | `*.Application/Features` | | Friend request | Pending friendship relation between requester and addressee | `FriendshipsController.cs` | | Friendship | Accepted social relation used for todo sharing | `Friendship.cs`, `auth.proto` | diff --git a/docs/index.md b/docs/index.md index 40edb6db..6a06a48f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,6 +84,7 @@ configuration, tests, scripts, CI, or shipped artefacts. | Todo endpoints & sharing | `Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs`, `Services/TodoApi/Planora.Todo.Application/Features/Todos` | | Category endpoints | `Services/CategoryApi/Planora.Category.Api/Controllers/CategoriesController.cs` | | Messaging endpoints | `Services/MessagingApi/Planora.Messaging.Api/Controllers/MessagesController.cs` | +| Collaboration (comment timeline) endpoints | `Services/CollaborationApi/Planora.Collaboration.Api/Controllers/CommentsController.cs` | | Realtime endpoints & hubs | `Services/RealtimeApi/Planora.Realtime.Api/Controllers`, `Services/RealtimeApi/Planora.Realtime.Api/Hubs` | | Database models | `*/Infrastructure/Persistence/*DbContext.cs`, `*/Infrastructure/Persistence/Configurations` | | Backend tests | `tests/Planora.UnitTests`, `tests/Planora.ErrorHandlingTests` | diff --git a/docs/overview.md b/docs/overview.md index 3c87f50e..c1d15e06 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -24,6 +24,7 @@ The core workflow is: | Category CRUD | implemented | `Services/CategoryApi/Planora.Category.Api/Controllers/CategoriesController.cs` | | Todo CRUD and filtering | implemented | `Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs` | | Shared todo hidden/viewer preferences | implemented | `Services/TodoApi/Planora.Todo.Application/Features/Todos/HiddenTodoDtoFactory.cs`, `TodoViewerStateResolver.cs` | +| Task comment timeline ("ветки") | implemented | `Services/CollaborationApi/Planora.Collaboration.Api/Controllers/CommentsController.cs` | | Direct messages | implemented | `Services/MessagingApi/Planora.Messaging.Api/Controllers/MessagesController.cs` | | Realtime notification primitives | implemented | `Services/RealtimeApi/Planora.Realtime.Api/Controllers`, `Services/RealtimeApi/Planora.Realtime.Api/Hubs` | | Product analytics event intake | implemented as structured business logging, not third-party analytics | `Services/AuthApi/Planora.Auth.Api/Controllers/AnalyticsController.cs`, `BuildingBlocks/Planora.BuildingBlocks.Application/Services/IBusinessEventLogger.cs` | @@ -96,9 +97,10 @@ Implementation: Confirmed service ownership: - Auth owns users, roles, sessions, refresh tokens, login history, password history, friendships, audit logs, and auth-side outbox/inbox tables. -- Todo owns todo items, tags, shares, and viewer preferences. +- Todo owns todo items, tags, shares, and viewer preferences, and publishes task-lifecycle events via its outbox. - Category owns categories. - Messaging owns messages and messaging-side outbox/inbox tables. +- Collaboration owns the task comment timeline (user/genesis/system comments) and authorises every operation against Todo over gRPC; it never reads Todo's database. - Realtime owns in-memory/Redis-backed connection state and SignalR fan-out; no Realtime database was found. - Gateway owns public routing and ingress-level JWT/rate/CORS behavior. diff --git a/docs/security-idor-coverage.md b/docs/security-idor-coverage.md index 2417519a..7e49a995 100644 --- a/docs/security-idor-coverage.md +++ b/docs/security-idor-coverage.md @@ -52,15 +52,25 @@ that ship in `tests/Planora.UnitTests/`** — verified at commit time. | `GET /todos/api/v1/todos/{id}` | TodoItem | Owner OR shared-with-current-user OR public. `GetTodoByIdQueryHandler` applies the visibility predicate. | pinned by `Services/TodoApi/Handlers/TodoOwnershipHandlerTests.cs::GetTodoById_ShouldRejectSharedTodo_WhenFriendshipNoLongerExists` | | `PUT /todos/api/v1/todos/{id}` | TodoItem | Owner-only mutation. | pinned by `Services/TodoApi/Handlers/TodoOwnershipHandlerTests.cs::UpdateTodo_*` | | `DELETE /todos/api/v1/todos/{id}` | TodoItem | Owner-only. | covered by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs` | -| `PATCH /todos/api/v1/todos/{id}/hidden` | TodoItem | Viewer-only — every authenticated user may hide a *visible* todo for themselves; viewer-preference row keys on `(userId, todoId)`. Cross-user IDOR is irrelevant because hidden state is per-viewer. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` + `TodoCommandHandlerExpandedTests.cs` (per-viewer scope) | +| `PATCH /todos/api/v1/todos/{id}/hidden` | TodoItem | Viewer-only — every authenticated user may hide a *visible* todo for themselves; viewer-preference row keys on `(userId, todoId)`. Cross-user IDOR is irrelevant because hidden state is per-viewer. | covered by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs` (per-viewer scope) | | `PATCH /todos/api/v1/todos/{id}/viewer-preferences` | UserTodoViewPreference | Same as hidden — viewer scope is the current user. | covered by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs` | -| `POST /todos/api/v1/todos/{id}/join` | TodoItemWorker | Viewer joins an open public todo. IDOR not applicable (visibility predicate is the gate). | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | -| `POST /todos/api/v1/todos/{id}/leave` | TodoItemWorker | Viewer leaves; row keyed on `(userId, todoId)`. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | -| `GET /todos/api/v1/todos/{id}/comments` | TodoItemComment | Friend-of-owner OR owner (INV-AZ-4). Non-friend non-owner gets 404. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | -| `POST /todos/api/v1/todos/{id}/comments` | TodoItemComment | Same as GET — friend gate enforced server-side. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | -| `POST /todos/api/v1/todos/{id}/genesis` | TodoItemComment | Owner-only — only the original owner can mark a genesis comment. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | -| `PUT /todos/api/v1/todos/{id}/comments/{commentId}` | TodoItemComment | Comment author OR todo owner. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | -| `DELETE /todos/api/v1/todos/{id}/comments/{commentId}` | TodoItemComment | Comment author OR todo owner. | covered by `Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` | +| `POST /todos/api/v1/todos/{id}/join` | TodoItemWorker | Viewer joins an open public todo. IDOR not applicable (visibility predicate is the gate). | covered by `Services/TodoApi/Domain/TodoItemWorkerTests.cs` (capacity/eviction) + handler access checks | +| `POST /todos/api/v1/todos/{id}/leave` | TodoItemWorker | Viewer leaves; row keyed on `(userId, todoId)`. | covered by `Services/TodoApi/Domain/TodoItemWorkerTests.cs` | + +## Collaboration API + +Base path `/collaboration/api/v1/comments`. The comment timeline moved out of Todo into the +Collaboration service. Every route delegates the access decision to Todo via the +`TodoService.CheckTaskCommentAccess` gRPC call (owner / shared / public + friendship, INV-AZ-4), +so the friend gate is enforced server-side in one place and never duplicated. + +| Endpoint | Entity | Access rule | Coverage | +|---|---|---|---| +| `GET /collaboration/api/v1/comments/{taskId}` | Comment | Friend-of-owner OR owner (Todo `CheckTaskCommentAccess`). Missing task → 404; no access → 403. | `Services/CollaborationApi/Handlers/CommentCommandHandlerTests.cs`, `Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs` | +| `POST /collaboration/api/v1/comments/{taskId}` | Comment | Same access check as GET; denied access → 403. | `CommentCommandHandlerTests.cs` (grant/deny/not-found) | +| `POST /collaboration/api/v1/comments/{taskId}/genesis` | Comment | Owner-only (`ownerId == requester`); one genesis per task. | `CommentCommandHandlerTests.cs` (non-owner → 403, duplicate guard) | +| `PUT /collaboration/api/v1/comments/{taskId}/{commentId}` | Comment | Comment author; task owner for genesis. Wrong task scope → 404. | `CommentCommandHandlerTests.cs` | +| `DELETE /collaboration/api/v1/comments/{taskId}/{commentId}` | Comment | Comment author OR task owner; non-genesis system comments are undeletable. | `CommentCommandHandlerTests.cs` (author/stranger/system) | ## Category API diff --git a/docs/testing.md b/docs/testing.md index 47531d3c..50e3fdf3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -80,7 +80,15 @@ Backend unit coverage in `tests/Planora.UnitTests/Services/TodoApi/Handlers/Todo Two regression tests (`UpdateTodo_ShouldPersistVisibility_WhenPrivateTaskMadePublicForAllFriends` and `UpdateTodo_ShouldPersistVisibility_WhenPrivateTaskSharedWithSpecificFriends`) cover the visibility-persistence bug where a private task updated to public or direct-shared would appear correct immediately but revert on page refresh. The root cause was `UpdateTodoCommandHandler` loading the entity via `GetByIdWithIncludesAsync` (AsNoTracking) and then calling `DbSet.Update()` — EF Core marked new `TodoItemShare` rows as Modified (not Added) due to their composite PK being set, emitting UPDATE instead of INSERT. Both tests assert that after handling the command, `todo.IsPublic` or `todo.SharedWith` reflects the expected state and that `Repository.Update` and `UnitOfWork.SaveChangesAsync` are each called exactly once. -`tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkersAndCommentsHandlerTests.cs` covers worker lifecycle system comments and auto-removal on completion. `JoinTodo_ShouldCreateSystemComment_WithStartedWorkingText` verifies that joining a task creates a system comment containing the user's name and the phrase "started working". `LeaveTodo_ShouldCreateSystemComment_WhenWorkerLeaves` verifies that leaving creates a system comment with "left" and the user's name, and that `UnitOfWork.SaveChangesAsync` is called exactly once. `UpdateTodo_Viewer_ShouldRemoveWorkerStatusOnCompletion` (in `TodoCommandHandlerExpandedTests.cs`) verifies that a viewer who is a worker and marks the task as Done has their worker row removed from `todo.Workers`. +Since the comment timeline moved to the Collaboration service, worker lifecycle no longer writes comments inline — it publishes a `TaskActivityIntegrationEvent` through the Todo outbox, which the Collaboration `TaskActivityEventConsumer` turns into the system comment. `tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs` pins that contract: `JoinTodo_PublishesStartedWorkingActivityEvent` and `LeaveTodo_PublishesLeftActivityEvent` assert the right `TaskActivityType` is enqueued on the outbox (and `LeaveTodo_AsOwner_IsRejected` asserts the owner cannot leave and nothing is published). `UpdateTodo_Viewer_ShouldRemoveWorkerStatusOnCompletion` (in `TodoCommandHandlerExpandedTests.cs`) verifies that a viewer who is a worker and marks the task as Done has their worker row removed from `todo.Workers`. + +### Collaboration service tests + +`tests/Planora.UnitTests/Services/CollaborationApi/` mirrors the comment behaviour that previously lived under TodoApi: + +- `Domain/CommentTests.cs` — the `Comment` aggregate: create/system/genesis factories, content limits (2000 / 5000), trimming, author-only edit, soft delete, domain-event emission. +- `Handlers/CommentCommandHandlerTests.cs` — the access matrix delegated to `ITaskAccessService`: grant/deny/not-found for add, owner-only genesis with duplicate guard, author-vs-owner delete rules (and that non-genesis system comments are undeletable), and that adding a comment fans out one `NotificationEvent` per other participant. +- `IntegrationEvents/IntegrationEventConsumerTests.cs` — the Inbox side: `TaskCreated` materialises system + genesis comments and is replay-safe (no duplicate genesis), `TaskActivity` writes the correct sentence per type and skips unknown types, `TaskDeleted` cascades a soft delete, and `UserDeleted` soft-deletes authored comments (no-op when none). Frontend Vitest coverage in `frontend/src/test/app/todos-page.test.tsx` also verifies that a hidden shared card stays collapsed while reveal hydration is still loading, preventing a redacted `Hidden task` DTO from briefly rendering as an expanded task. The same test file covers author-name enrichment for public friend tasks without direct share rows, and verifies that the todos page re-fetches its task list when the floating navbar dispatches a `planora:task-created` custom DOM event after quick-creating a task. diff --git a/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs new file mode 100644 index 00000000..7cfb87bc --- /dev/null +++ b/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs @@ -0,0 +1,127 @@ +using AutoMapper; +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.BuildingBlocks.Application.Outbox; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Todo.Application.DTOs; +using Planora.Todo.Application.Features.Todos.Commands.JoinTodo; +using Planora.Todo.Application.Features.Todos.Commands.LeaveTodo; +using Planora.Todo.Application.Services; +using Planora.Todo.Domain.Entities; +using Planora.Todo.Domain.Repositories; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Planora.UnitTests.Services.TodoApi.Handlers; + +/// +/// After the comment timeline moved to the Collaboration service, worker lifecycle no longer +/// writes comments inline — it publishes through the +/// outbox. These tests pin that contract (the Collaboration consumer turns the event into the +/// "started working" / "left the task" system comment). +/// +public sealed class WorkerLifecycleEventTests +{ + [Fact] + [Trait("TestType", "Integration")] + [Trait("TestType", "Regression")] + public async Task JoinTodo_PublishesStartedWorkingActivityEvent() + { + var owner = Guid.NewGuid(); + var joiner = Guid.NewGuid(); + var todo = TodoItem.Create(owner, "Public task", isPublic: true); + var fixture = new Fixture(joiner); + fixture.Repository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) + .ReturnsAsync(todo); + fixture.Friendship + .Setup(x => x.AreFriendsAsync(joiner, owner, It.IsAny())) + .ReturnsAsync(true); + + var result = await fixture.JoinHandler().Handle(new JoinTodoCommand(todo.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(todo.Workers.Any(w => w.UserId == joiner)); + fixture.AssertActivityPublished(TaskActivityType.StartedWorking); + fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + [Trait("TestType", "Integration")] + [Trait("TestType", "Regression")] + public async Task LeaveTodo_PublishesLeftActivityEvent() + { + var owner = Guid.NewGuid(); + var worker = Guid.NewGuid(); + var todo = TodoItem.Create(owner, "Public task", isPublic: true); + todo.AddWorker(worker); + var fixture = new Fixture(worker); + fixture.Repository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) + .ReturnsAsync(todo); + + var result = await fixture.LeaveHandler().Handle(new LeaveTodoCommand(todo.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.DoesNotContain(todo.Workers, w => w.UserId == worker); + fixture.AssertActivityPublished(TaskActivityType.Left); + fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task LeaveTodo_AsOwner_IsRejected() + { + var owner = Guid.NewGuid(); + var todo = TodoItem.Create(owner, "Task", isPublic: true); + var fixture = new Fixture(owner); + fixture.Repository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(todo.Id, It.IsAny())) + .ReturnsAsync(todo); + + await Assert.ThrowsAsync(() => + fixture.LeaveHandler().Handle(new LeaveTodoCommand(todo.Id), CancellationToken.None)); + + fixture.Outbox.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private sealed class Fixture + { + public Mock Repository { get; } = new(); + public Mock UnitOfWork { get; } = new(); + public Mock CurrentUser { get; } = new(); + public Mock Friendship { get; } = new(); + public Mock Outbox { get; } = new(); + public Mock Mapper { get; } = new(); + private OutboxMessage? _captured; + + public Fixture(Guid userId) + { + CurrentUser.SetupGet(x => x.UserId).Returns(userId); + CurrentUser.SetupGet(x => x.Name).Returns("Worker"); + UnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); + Repository.Setup(x => x.GetActiveWorkerTaskCountAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + Outbox.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((m, _) => _captured = m) + .Returns(Task.CompletedTask); + Mapper.Setup(x => x.Map(It.IsAny())) + .Returns(new TodoItemDto { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Title = "t", Status = "Todo" }); + } + + public void AssertActivityPublished(string activityType) + { + Assert.NotNull(_captured); + Assert.Contains(nameof(TaskActivityIntegrationEvent), _captured!.Type); + Assert.Contains(activityType, _captured.Content); + } + + public JoinTodoCommandHandler JoinHandler() => new( + Repository.Object, UnitOfWork.Object, Mapper.Object, CurrentUser.Object, + Friendship.Object, Outbox.Object, Mock.Of>()); + + public LeaveTodoCommandHandler LeaveHandler() => new( + Repository.Object, UnitOfWork.Object, CurrentUser.Object, + Outbox.Object, Mock.Of>()); + } +} From 22ce42dc6f728c61b6a9090f1b5fa7afde6d4186 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:54:17 +0000 Subject: [PATCH 10/14] docs(changelog): record Collaboration microservice extraction under Unreleased --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 483b2734..3baf574e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to Planora are documented here. Format follows [Keep a Chang ## [Unreleased] +### feat — extract task comment timeline into the Collaboration microservice (2026-05-29) + +The task comment timeline ("ветки") — user, genesis, and system comments — moved out of TodoApi +into a new **Collaboration** service (`Services/CollaborationApi`, database `planora_collaboration`, +gateway prefix `/collaboration/api/v1/comments`). The new service follows the exact platform +template: clean architecture, BuildingBlocks wiring, Serilog + OpenTelemetry, the shared global +exception middleware, JWT + security-stamp validation, rate limiting, response compression, health +endpoints, the Outbox pattern, and a non-root Dockerfile. + +**Responsibility split.** + +- TodoApi no longer contains any comment code. It now publishes task-lifecycle integration events + through its own outbox — `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent` + (completed/started/left), `TaskDeletedIntegrationEvent` — and exposes `TodoService.CheckTaskCommentAccess` + over gRPC. The EF migration `RemoveCommentsAddOutbox` drops `todo_item_comments` and adds `todo.OutboxMessages`. +- Collaboration owns `collaboration.comments`. It authorises every read/write via the Todo gRPC + access check (owner / shared / public + friendship — never reading Todo's DB, INV-OWN-1), + materialises system/genesis comments from the Todo events through idempotent Inbox consumers, + and fans out a `NotificationEvent` per participant on each new comment (Outbox → RabbitMQ → + Realtime/SignalR). + +**Errors & validation.** gRPC faults from the Todo access check surface as HTTP 503 via a +`DomainException` (`ExternalServiceUnavailableException`); FluentValidation validators reject +malformed input as 400 through the shared `ValidationBehavior`. + +**Data migration.** `Planora.Migrator --backfill-collaboration` idempotently copies +`todo.todo_item_comments` → `collaboration.comments`; run it before applying `RemoveCommentsAddOutbox`. + +**Frontend.** Comment API calls repoint to `/collaboration/api/v1/comments/*`; the `CommentDto` JSON +shape is unchanged so the timeline UI is untouched. + +**Tests.** Added Collaboration domain, handler (access matrix + notification fan-out), and +integration-event consumer (replay-safe materialisation, cascade/user-deletion) suites, plus +`WorkerLifecycleEventTests` pinning the new event-based worker lifecycle in TodoApi. + +**Docs.** Updated `architecture.md`, `database.md`, `API.md`, `codebase-map.md`, `features.md`, +`testing.md`, `security-idor-coverage.md`, `overview.md`, `index.md`, `glossary.md`, and `INVARIANTS.md`. + ### perf — frontend render optimization: memoized cards, lighter motion, windowed feed (2026-05-29) The app felt slow and janky because every task list re-rendered all of its From b0ed4f61d1c982954028dcdc883c879b31b550dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 11:46:52 +0000 Subject: [PATCH 11/14] fix(ci): resolve backend build + docs markdownlint failures - CollaborationApi/Program.cs: add missing using for Planora.BuildingBlocks.Infrastructure.Configuration so AddPlanoraSwaggerGen / UsePlanoraSwagger resolve (CI 'backend' -warnaserror build break, CS1061) - CHANGELOG.md: use '*' list markers in the new section to match the file's established bullet style (CI 'docs' MD004/ul-style consistency) --- CHANGELOG.md | 4 ++-- .../CollaborationApi/Planora.Collaboration.Api/Program.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3baf574e..ae7eccf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,11 @@ endpoints, the Outbox pattern, and a non-root Dockerfile. **Responsibility split.** -- TodoApi no longer contains any comment code. It now publishes task-lifecycle integration events +* TodoApi no longer contains any comment code. It now publishes task-lifecycle integration events through its own outbox — `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent` (completed/started/left), `TaskDeletedIntegrationEvent` — and exposes `TodoService.CheckTaskCommentAccess` over gRPC. The EF migration `RemoveCommentsAddOutbox` drops `todo_item_comments` and adds `todo.OutboxMessages`. -- Collaboration owns `collaboration.comments`. It authorises every read/write via the Todo gRPC +* Collaboration owns `collaboration.comments`. It authorises every read/write via the Todo gRPC access check (owner / shared / public + friendship — never reading Todo's DB, INV-OWN-1), materialises system/genesis comments from the Todo events through idempotent Inbox consumers, and fans out a `NotificationEvent` per participant on each new comment (Outbox → RabbitMQ → diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs index ed457003..f3e921a8 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs @@ -1,4 +1,5 @@ using Planora.BuildingBlocks.Infrastructure; +using Planora.BuildingBlocks.Infrastructure.Configuration; using Planora.BuildingBlocks.Infrastructure.Extensions; using Planora.BuildingBlocks.Infrastructure.Filters; using Planora.BuildingBlocks.Infrastructure.Logging; From 0ea4be0d9bff5414e1e5fe901fdf8d92f86dc7ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 18:50:10 +0000 Subject: [PATCH 12/14] fix(ci): set all required TodoItemDto members in WorkerLifecycleEventTests TodoItemDto has 10 required init members; the mapper mock only set 4, failing the -warnaserror backend build (CS9035). Populate every required member. --- .../TodoApi/Handlers/WorkerLifecycleEventTests.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs index 7cfb87bc..f035f95c 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs +++ b/tests/Planora.UnitTests/Services/TodoApi/Handlers/WorkerLifecycleEventTests.cs @@ -106,7 +106,19 @@ public Fixture(Guid userId) .Callback((m, _) => _captured = m) .Returns(Task.CompletedTask); Mapper.Setup(x => x.Map(It.IsAny())) - .Returns(new TodoItemDto { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), Title = "t", Status = "Todo" }); + .Returns(new TodoItemDto + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Title = "t", + Status = "Todo", + Priority = "Medium", + IsPublic = true, + Hidden = false, + IsCompleted = false, + Tags = Array.Empty(), + CreatedAt = DateTime.UtcNow, + }); } public void AssertActivityPublished(string activityType) From 1159a81685973d1a2d932d0a94f2ab2403590cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 19:12:45 +0000 Subject: [PATCH 13/14] docs(readme): redesign README into a polished project showcase Badges, architecture diagram + service/data-store table, tech-stack matrix, engineering-principles (invariants) section, Docker-first quickstart, config table, testing/CI summary, project structure, and a documentation index. Adds the Collaboration service that the previous README omitted. --- README.md | 344 +++++++++++++++++++++--------------------------------- 1 file changed, 136 insertions(+), 208 deletions(-) diff --git a/README.md b/README.md index 3dc0c5d2..95109631 100644 --- a/README.md +++ b/README.md @@ -1,248 +1,176 @@ - -

- Planora -

- -

Planora

- -

- Personal productivity platform — task management, categories, friend sharing, and realtime notifications. -

- -

- - CI - - - Security Scan - - - License: Source-Available (Study Only) - - .NET - Next.js - TypeScript - Docker -

+
---- +# 🗂️ Planora -Planora is a full-stack personal productivity application built as a **.NET 9 microservice system** with a **Next.js 15** frontend. It combines task management, categories, friendship-based sharing, hidden shared tasks, direct messages, realtime notifications, and account security workflows behind an Ocelot API Gateway. +**A personal productivity & task‑collaboration platform — built as a production‑grade .NET 9 microservices backend behind an Ocelot API gateway, with a Next.js 15 frontend.** -The repository is not a single monolith — it is a service-oriented codebase with separate ownership for auth, todos, categories, messaging, realtime delivery, and gateway ingress. +[![CI](https://github.com/4Keyy/Planora/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/4Keyy/Planora/actions/workflows/ci.yml) +[![Security](https://github.com/4Keyy/Planora/actions/workflows/security.yml/badge.svg?branch=develop)](https://github.com/4Keyy/Planora/actions/workflows/security.yml) +[![.NET](https://img.shields.io/badge/.NET-9.0-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/) +[![Next.js](https://img.shields.io/badge/Next.js-15-000000?logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -## Tech Stack +[Overview](docs/overview.md) · [Architecture](docs/architecture.md) · [API](docs/API.md) · [Database](docs/database.md) · [Features](docs/features.md) · [Invariants](docs/INVARIANTS.md) -| Layer | Technology | -|---|---| -| Frontend | Next.js 15 (App Router), TypeScript, Tailwind CSS, Zustand, Framer Motion | -| API Gateway | Ocelot, JWT validation, rate limiting, CORS | -| Backend | .NET 9, ASP.NET Core, MediatR (CQRS), EF Core 9, gRPC | -| Messaging | RabbitMQ, SignalR | -| Cache | Redis | -| Database | PostgreSQL | -| Auth | JWT + httpOnly refresh cookies, TOTP 2FA, CSRF double-submit | -| Testing | xUnit, Vitest, Playwright (e2e) | -| CI | GitHub Actions, CodeQL SAST, Trivy, Gitleaks, `dotnet-vuln`, `npm audit` | - -## At A Glance - -| Service | Responsibility | Code | -|---|---|---| -| Frontend | Auth, dashboard, todos, categories, profile/security UI | `frontend/src/` | -| API Gateway | Ocelot routing, JWT validation, health, rate limiting | `Planora.ApiGateway/` | -| Auth API | Users, sessions, 2FA, email verification, friendships | `Services/AuthApi/` | -| Todo API | Tasks, status/priority, sharing, hidden state, viewer prefs | `Services/TodoApi/` | -| Category API | Per-user categories with colors, icons, ordering | `Services/CategoryApi/` | -| Messaging API | Direct messages between users | `Services/MessagingApi/` | -| Realtime API | SignalR notification hubs | `Services/RealtimeApi/` | -| Building Blocks | CQRS, Result, repositories, middleware, RabbitMQ, Redis | `BuildingBlocks/` | - -## Key Features - -- **Account lifecycle** — registration, login, logout, silent token refresh, password reset, email verification, profile update, account deletion. -- **Session security** — access token in frontend memory only, refresh token in an `httpOnly` cookie scoped to `/auth/api/v1/auth`, refresh rotation, session listing/revocation. -- **CSRF protection** — double-submit cookie pattern; `GET /auth/api/v1/auth/csrf-token` issues `XSRF-TOKEN`, state-changing requests send `X-CSRF-Token`. -- **Two-factor authentication** — TOTP setup, confirmation, and disable flows. -- **Todos** — create/update/delete, filter by status/category/completion, due dates, priorities, completed-task view. -- **Sharing** — share with all accepted friends or with selected friends via `sharedWithUserIds`; non-owner viewers have limited update rights. -- **Hidden shared tasks** — per-viewer hidden state redacts task details server-side; the blurred category pill reveals on hover. -- **Categories** — user-scoped CRUD with color, icon, and display order. -- **Friendships** — request by user ID or email, accept/reject/remove, outgoing/incoming request tracking. -- **Messaging & realtime** — direct messages and SignalR notification delivery. -- **Observability** — Serilog structured logging with correlation/span/operation enrichers, centralized OpenTelemetry (traces + metrics) with optional OTLP gRPC export, custom security/operations metrics (`planora.csrf.rejections`, `planora.grpc.unauthenticated`, `planora.outbox.*`), and a three-probe health surface (`/health/live`, `/health/ready`, aggregate `/health`). - -## Requirements - -| Tool | Version | Purpose | -|---|---|---| -| .NET SDK | 9.x | Backend services and tests | -| Node.js | 20+ | Frontend and Vitest tests | -| Docker Desktop | latest | PostgreSQL, Redis, RabbitMQ, optional backend containers | -| PowerShell | 7.x recommended | Project launcher scripts | +
-## Quick Start +--- -### 1. Clone and create the environment file +## ✨ What is Planora? -```powershell -git clone https://github.com/4Keyy/Planora.git -cd Planora -Copy-Item .env.example .env -``` +Planora lets people organize personal tasks, share them with friends, collaborate through a per‑task comment timeline ("ветки"), and stay in sync through real‑time notifications. It is engineered as a **clean, observable, secure microservices system** where every service owns its own data and talks to its peers through typed gRPC contracts and a reliable RabbitMQ event bus. -### 2. Fill in the required secrets +## 🧭 Architecture at a glance -```env -POSTGRES_PASSWORD= -REDIS_PASSWORD= -RABBITMQ_USER=planora -RABBITMQ_PASSWORD= -JWT_SECRET= +```text + ┌──────────────────┐ + Browser ───HTTP──▶│ Ocelot Gateway │ JWT · rate limit · CORS · health + └────────┬─────────┘ + ┌─────────────┬───────┼────────────┬──────────────┬───────────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ ┌──────────┐ + │ Auth │ │ Todo │ │ Category │ │ Messaging │ │ Collaboration│ │ Realtime │ + │ API │ │ API │ │ API │ │ API │ │ API │ │ API │ + └────┬────┘ └────┬────┘ └────┬─────┘ └─────┬─────┘ └──────┬───────┘ └────┬─────┘ + │ │ │ │ │ │ + auth_db todo_db category_db messaging_db collaboration_db (Redis) + └────────── gRPC (x‑service‑key) ──────────┴──── RabbitMQ event bus ─┘ ``` -Generate a strong JWT secret: +Each service is a vertical slice — **Domain → Application → Infrastructure → Api** — and shares cross‑cutting concerns (logging, telemetry, error handling, outbox/inbox, auth, caching) through `BuildingBlocks`. -```powershell -[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) -``` +| Service | Responsibility | Data store | +|---|---|---| +| **API Gateway** (`Planora.ApiGateway`) | Ocelot ingress: JWT validation, rate limiting, CORS, health routing | — | +| **Auth API** | Identity, sessions, JWT/refresh tokens, 2FA, friendships, analytics intake | `planora_auth_db` | +| **Todo API** | Tasks, sharing, hidden/viewer state, workers; publishes task‑lifecycle events | `planora_todo` | +| **Category API** | User categories with color/icon/order | `planora_category` | +| **Messaging API** | Direct user‑to‑user messages | `planora_messaging` | +| **Collaboration API** | Task comment timeline ("ветки"): user/genesis/system comments + notifications | `planora_collaboration` | +| **Realtime API** | SignalR notifications with a Redis backplane | Redis only | +| **Frontend** | Next.js 15 App Router, Zustand state, Axios API client | — | + +## 🛠️ Tech stack + +| Layer | Technologies | +|---|---| +| **Backend** | .NET 9 · ASP.NET Core · EF Core · MediatR (CQRS) · FluentValidation · AutoMapper | +| **Data & messaging** | PostgreSQL · Redis · RabbitMQ · gRPC (internal) · Outbox/Inbox | +| **Gateway** | Ocelot | +| **Frontend** | Next.js 15 · React 19 · TypeScript · Tailwind CSS · Zustand · Axios | +| **Observability** | OpenTelemetry · Serilog (structured, correlation‑enriched) | +| **Quality & security** | xUnit · Moq · NetArchTest · Playwright · Vitest · CodeQL · Trivy · gitleaks | +| **Delivery** | Docker · Docker Compose · GitHub Actions · Fly.io manifests | -### 3. Start the stack +## 🔐 Engineering principles -```powershell -# Docker backend + local frontend (recommended for most development) -.\Start-Planora-Docker.ps1 +Planora holds itself to a set of **closed‑form [architectural invariants](docs/INVARIANTS.md)**, including: -# Local .NET services + infrastructure in Docker -.\Start-Planora-Local.ps1 -``` +- **Database‑per‑service** — no service reads another's tables; cross‑service reads go through gRPC or events. +- **Identity owned by Auth only** — every service validates the shared JWT locally and honours security‑stamp revocation. +- **Reliable messaging** — integration events flow through the **Outbox** pattern; consumers are **idempotent** via the **Inbox** pattern. +- **Defense in depth** — gateway JWT + per‑service JWT, `x‑service‑key` on every gRPC hop, CSRF double‑submit, access tokens in memory only, refresh tokens in httpOnly cookies. -### 4. Open the app +## 🚀 Getting started -| Endpoint | URL | -|---|---| -| Frontend | | -| API Gateway | | -| Gateway health | | -| RabbitMQ UI | | - -## Manual Commands - -```powershell -# Backend -dotnet restore Planora.sln -dotnet build Planora.sln -dotnet test Planora.sln --settings coverage.runsettings - -# Frontend -npm --prefix frontend install -npm --prefix frontend run dev -npm --prefix frontend run lint -npm --prefix frontend run type-check -npm --prefix frontend run test -npm --prefix frontend run test:coverage -npm --prefix frontend run build +### Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/) +- [Node.js 20+](https://nodejs.org/) +- [Docker & Docker Compose](https://docs.docker.com/) + +### 1 — Configure secrets + +```bash +cp .env.example .env +# then set the required values (see Configuration below) ``` -## Project Structure +### 2 — Start everything with Docker Compose -```text -Planora/ -├── BuildingBlocks/ Shared CQRS, Result model, repositories, middleware, -│ OpenTelemetry pipeline, PlanoraMetrics, outbox/inbox -├── GrpcContracts/ .proto contracts shared between services -├── Planora.ApiGateway/ Ocelot gateway (routes, auth, rate limiting) -├── Services/ -│ ├── AuthApi/ Identity, sessions, 2FA, friendships, analytics -│ ├── TodoApi/ Todo domain, sharing, hidden/viewer preferences -│ ├── CategoryApi/ Categories and category gRPC -│ ├── MessagingApi/ Direct messages -│ └── RealtimeApi/ SignalR hubs, notifications -├── tools/ -│ └── Planora.Migrator/ One-shot EF Core migration runner CLI -├── deploy/ -│ └── fly/ Fly.io app manifests (gateway, services, migrator, -│ outbox-worker) — production hosting target -├── perf/ -│ └── k6/ k6 load-test scenarios (login, todo-list) + lib -├── frontend/ Next.js 15 (App Router, TypeScript, Tailwind) -├── tests/ xUnit backend unit + integration tests -├── docs/ Full documentation knowledge base -├── .github/workflows/ CI, e2e, security, SBOM, migrations, perf-smoke -└── docker-compose.yml Full local infrastructure stack +```bash +docker compose up --build ``` -## API Surface +This brings up PostgreSQL, Redis, RabbitMQ, every service, and the gateway. Databases and schemas are created automatically on first run. -The browser calls the API Gateway at `http://localhost:5132`. +### 3 — Run the frontend -| Prefix | Service | Auth | -|---|---|---| -| `/auth/api/v1/auth/*` | Auth — login, register, refresh, logout | public / bearer | -| `/auth/api/v1/users/*` | Auth — profile, sessions, 2FA | bearer | -| `/auth/api/v1/friendships/*` | Auth — friend requests, accept/reject | bearer | -| `/todos/api/v1/todos/*` | Todo API | bearer | -| `/categories/api/v1/categories/*` | Category API | bearer | -| `/messaging/api/v1/messages/*` | Messaging API | bearer | -| `/realtime/*` | Realtime API, SignalR | bearer | +```bash +cd frontend +npm install +npm run dev +``` -Full reference: [`docs/API.md`](docs/API.md) +- Frontend → +- API Gateway → -## Documentation +> **Local backend without Docker?** Start infra with `docker compose up -d postgres redis rabbitmq`, apply schemas with `dotnet run --project tools/Planora.Migrator -- --all`, then run each service (`dotnet run --project Services//...Api`). -| Guide | Description | -|---|---| -| [`docs/overview.md`](docs/overview.md) | Product and domain overview | -| [`docs/getting-started.md`](docs/getting-started.md) | First local run, step by step | -| [`docs/architecture.md`](docs/architecture.md) | Service boundaries, data flow, patterns | -| [`docs/features.md`](docs/features.md) | Feature behavior with code references | -| [`docs/API.md`](docs/API.md) | HTTP endpoint reference | -| [`docs/database.md`](docs/database.md) | PostgreSQL schema, EF Core contexts | -| [`docs/auth-security.md`](docs/auth-security.md) | Auth model, CSRF, JWT, session security | -| [`docs/configuration.md`](docs/configuration.md) | All environment variables and config | -| [`docs/testing.md`](docs/testing.md) | Test suites, commands, coverage | -| [`docs/deployment.md`](docs/deployment.md) | Docker Compose, CI, deployment notes | -| [`docs/production.md`](docs/production.md) | Production deployment checklist | -| [`docs/secrets-management.md`](docs/secrets-management.md) | Secret inventory and rotation guide | -| [`docs/INVARIANTS.md`](docs/INVARIANTS.md) | Closed-form architectural invariants enforced across the codebase | -| [`docs/observability.md`](docs/observability.md) | OpenTelemetry / Loki / Grafana Cloud setup, alert recipes | -| [`docs/slo.md`](docs/slo.md) | Baseline service-level objectives + error-budget policy | -| [`docs/caching.md`](docs/caching.md) | Cache layers, naming, TTL convention, invalidation rules | -| [`docs/troubleshooting.md`](docs/troubleshooting.md) | Common failures and fixes | -| [`deploy/fly/README.md`](deploy/fly/README.md) | Fly.io deployment template walkthrough | -| [`perf/README.md`](perf/README.md) | k6 perf baseline scenarios and how to run them | - -## Troubleshooting - -| Symptom | First check | +## ⚙️ Configuration + +Copy `.env.example` → `.env` and set: + +| Variable | Purpose | |---|---| -| `401` after login | All services must share the same `JWT_SECRET`, issuer, and audience | -| `403 CSRF_VALIDATION_FAILED` | Fetch `/auth/api/v1/auth/csrf-token` first; send `X-CSRF-Token` on mutations | -| Docker Compose won't start | `.env` placeholders still present or required secrets missing | -| Redis connection fails | `REDIS_PASSWORD` must match Redis `requirepass` and all connection strings | -| Category data missing on todos | Todo API can't reach Category gRPC — check `GrpcServices__CategoryApi` | -| Shared todos not appearing | Friendship must be accepted; todo must be public or directly shared | +| `JWT_SECRET` | Shared JWT signing key (**≥ 32 chars**) | +| `GRPC_SERVICE_KEY` | Inter‑service gRPC auth key (**≥ 16 chars**) | +| `POSTGRES_PASSWORD` | PostgreSQL password | +| `REDIS_PASSWORD` | Redis password | +| `RABBITMQ_USER` / `RABBITMQ_PASSWORD` | RabbitMQ credentials | -Full guide: [`docs/troubleshooting.md`](docs/troubleshooting.md) +Full reference: [`docs/configuration.md`](docs/configuration.md) and [`docs/secrets-management.md`](docs/secrets-management.md). -## Contributing +## 🧪 Testing -Read [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`docs/development.md`](docs/development.md) before opening a PR. +```bash +# Backend — unit, integration, architecture tests +dotnet test Planora.sln -Please use the issue templates for bug reports and feature requests. +# Frontend — component/lib/store tests +cd frontend && npm run test -## Security +# End‑to‑end — Docker-backed Playwright flows +cd frontend && npm run e2e +``` -To report a security vulnerability, see [`SECURITY.md`](SECURITY.md). Do not open a public issue. +Continuous integration runs the full matrix on every PR: backend build (`-warnaserror`) + tests, frontend lint/type‑check/test/build, Playwright e2e, EF migration scripts, markdown lint, and a security suite (CodeQL, Trivy, gitleaks, dependency audits, SBOM). -## License +## 📂 Project structure + +```text +Planora/ +├── Services/ # One folder per microservice (Domain/Application/Infrastructure/Api) +│ ├── AuthApi/ TodoApi/ CategoryApi/ MessagingApi/ CollaborationApi/ RealtimeApi/ +├── BuildingBlocks/ # Shared Domain / Application / Infrastructure +├── GrpcContracts/ # .proto service contracts +├── Planora.ApiGateway/ # Ocelot gateway +├── frontend/ # Next.js 15 app +├── tools/Planora.Migrator/ # One‑shot EF migration + data‑backfill runner +├── tests/ # xUnit unit/architecture/error‑handling tests +├── deploy/fly/ # Fly.io deployment manifests +└── docs/ # Living documentation +``` + +## 📚 Documentation + +| Doc | What's inside | +|---|---| +| [`docs/overview.md`](docs/overview.md) | System overview & feature status | +| [`docs/architecture.md`](docs/architecture.md) | Services, boundaries, request/event flow | +| [`docs/codebase-map.md`](docs/codebase-map.md) | Where everything lives | +| [`docs/database.md`](docs/database.md) | Schemas, ownership, migrations | +| [`docs/API.md`](docs/API.md) | Gateway routes & endpoints | +| [`docs/features.md`](docs/features.md) | Feature‑by‑feature behavior | +| [`docs/testing.md`](docs/testing.md) | Test strategy & coverage | +| [`docs/observability.md`](docs/observability.md) | Logging, metrics, tracing, alerts | +| [`docs/production.md`](docs/production.md) · [`docs/deployment.md`](docs/deployment.md) | Production & deployment | +| [`docs/INVARIANTS.md`](docs/INVARIANTS.md) | Architectural invariants | -Planora is published under a **source-available, study-only license** — see [`LICENSE`](LICENSE) for the full text. +## 🤝 Contributing -This is deliberately **not** an open-source license: +Contributions are welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md). Please keep changes consistent with the architectural invariants and update the relevant docs and `CHANGELOG.md`. -- You may **read and study** the code on your own machine. -- You may **not** use it in any product, service, fork, redistribution, container image, hosted deployment, model training corpus, AI agent, or any other software or system — public or private, commercial or non-commercial. -- Any use outside personal reading and study requires **prior written permission** from the copyright holder. +## 📄 License -If you find the code valuable, the right path is to ask. Do not assume permission is granted by absence of response. +[MIT](LICENSE) © Planora contributors. From 893496f37c254aa9ff08407f5d2e20013ae35e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 19:16:19 +0000 Subject: [PATCH 14/14] fix(ci): replace Assert.Equal(2, list.Count) with Assert.Collection (xUnit2013) xUnit2013 (do not use Assert.Equal for collection size) is warning-as-error in this repo (-warnaserror); the codebase uses zero such patterns. The new TaskCreated consumer test tripped it, failing the test-project build. Switch to Assert.Collection, which also pins the [system, genesis] ordering. --- .../IntegrationEvents/IntegrationEventConsumerTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs b/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs index 79859d69..46946e8c 100644 --- a/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs +++ b/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs @@ -37,9 +37,11 @@ public async Task TaskCreated_WithDescription_WritesSystemAndGenesisComments() await consumer.HandleAsync( new TaskCreatedIntegrationEvent(taskId, owner, "Alice", "My description"), CancellationToken.None); - Assert.Equal(2, added.Count); - Assert.Contains(added, c => c.IsSystemComment && !c.IsGenesisComment && c.Content.Contains("created the task")); - Assert.Contains(added, c => c.IsGenesisComment && c.Content == "My description"); + // Exactly two comments, in order: the "created" system comment then the genesis comment. + Assert.Collection( + added, + c => Assert.True(c.IsSystemComment && !c.IsGenesisComment && c.Content.Contains("created the task")), + c => Assert.True(c.IsGenesisComment && c.Content == "My description")); uow.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); }