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/CHANGELOG.md b/CHANGELOG.md
index 483b2734..ae7eccf3 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
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/README.md b/README.md
index 3dc0c5d2..95109631 100644
--- a/README.md
+++ b/README.md
@@ -1,248 +1,176 @@
-
-
-
-
-
-Planora
-
-
- Personal productivity platform — task management, categories, friend sharing, and realtime notifications.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
----
+# 🗂️ 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.
+[](https://github.com/4Keyy/Planora/actions/workflows/ci.yml)
+[](https://github.com/4Keyy/Planora/actions/workflows/security.yml)
+[](https://dotnet.microsoft.com/)
+[](https://nextjs.org/)
+[](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.
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..f3e921a8
--- /dev/null
+++ b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs
@@ -0,0 +1,220 @@
+using Planora.BuildingBlocks.Infrastructure;
+using Planora.BuildingBlocks.Infrastructure.Configuration;
+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/Exceptions/ExternalServiceUnavailableException.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs
new file mode 100644
index 00000000..e8004054
--- /dev/null
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Exceptions/ExternalServiceUnavailableException.cs
@@ -0,0 +1,25 @@
+using Planora.BuildingBlocks.Domain.Exceptions;
+
+namespace Planora.Collaboration.Application.Exceptions
+{
+ ///
+ /// 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 override ErrorCategory Category => ErrorCategory.ServiceUnavailable;
+
+ public ExternalServiceUnavailableException(string serviceName, string operationName, Exception innerException)
+ : base(
+ $"{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/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/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandValidator.cs
similarity index 72%
rename from Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandValidator.cs
index b5ea42d8..5e89f5ba 100644
--- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddComment/AddCommentCommandValidator.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddComment/AddCommentCommandValidator.cs
@@ -1,10 +1,10 @@
-namespace Planora.Todo.Application.Features.Todos.Commands.AddComment
+namespace Planora.Collaboration.Application.Features.Comments.Commands.AddComment
{
public sealed class AddCommentCommandValidator : AbstractValidator
{
public AddCommentCommandValidator()
{
- RuleFor(x => x.TodoId).NotEmpty().WithMessage("TodoId is required");
+ RuleFor(x => x.TaskId).NotEmpty().WithMessage("TaskId is required");
RuleFor(x => x.Content)
.NotEmpty().WithMessage("Content cannot be empty")
.MaximumLength(2000).WithMessage("Content cannot exceed 2000 characters");
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/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs
similarity index 55%
rename from Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs
index 7fe1cf1d..3483ab99 100644
--- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandHandler.cs
@@ -1,52 +1,55 @@
+using MediatR;
+using Planora.BuildingBlocks.Application.Context;
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;
+using Planora.Collaboration.Application.DTOs;
+using Planora.Collaboration.Application.Services;
+using Planora.Collaboration.Domain.Entities;
+using Planora.Collaboration.Domain.Repositories;
-namespace Planora.Todo.Application.Features.Todos.Commands.AddGenesisComment
+namespace Planora.Collaboration.Application.Features.Comments.Commands.AddGenesisComment
{
- public sealed class AddGenesisCommentCommandHandler : IRequestHandler>
+ public sealed class AddGenesisCommentCommandHandler : IRequestHandler>
{
- private readonly ITodoRepository _todoRepository;
- private readonly ITodoCommentRepository _commentRepository;
+ private readonly ICommentRepository _commentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserContext _currentUserContext;
+ private readonly ITaskAccessService _taskAccessService;
public AddGenesisCommentCommandHandler(
- ITodoRepository todoRepository,
- ITodoCommentRepository commentRepository,
+ ICommentRepository commentRepository,
IUnitOfWork unitOfWork,
- ICurrentUserContext currentUserContext)
+ ICurrentUserContext currentUserContext,
+ ITaskAccessService taskAccessService)
{
- _todoRepository = todoRepository;
_commentRepository = commentRepository;
_unitOfWork = unitOfWork;
_currentUserContext = currentUserContext;
+ _taskAccessService = taskAccessService;
}
- public async Task> Handle(AddGenesisCommentCommand request, CancellationToken cancellationToken)
+ 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);
+ var access = await _taskAccessService.CheckCommentAccessAsync(request.TaskId, userId, cancellationToken);
+ if (!access.Exists)
+ throw new EntityNotFoundException("Task", request.TaskId);
- if (todoItem.UserId != userId)
+ if (access.OwnerId != userId)
throw new ForbiddenException("Only the task owner can add a description");
- var existing = await _commentRepository.GetGenesisCommentAsync(request.TodoId, cancellationToken);
+ 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"));
+ 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);
+ var comment = Comment.CreateGenesis(request.TaskId, request.Content, authorName);
await _commentRepository.AddAsync(comment, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
@@ -54,9 +57,9 @@ public async Task> Handle(AddGenesisCommentCommand reques
? null
: _currentUserContext.ProfilePictureUrl;
- return Result.Success(new TodoCommentDto(
+ return Result.Success(new CommentDto(
comment.Id,
- comment.TodoItemId,
+ comment.TaskId,
comment.AuthorId,
comment.AuthorName,
authorAvatarUrl,
diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandValidator.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandValidator.cs
new file mode 100644
index 00000000..9357b178
--- /dev/null
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/AddGenesisComment/AddGenesisCommentCommandValidator.cs
@@ -0,0 +1,13 @@
+namespace Planora.Collaboration.Application.Features.Comments.Commands.AddGenesisComment
+{
+ public sealed class AddGenesisCommentCommandValidator : AbstractValidator
+ {
+ public AddGenesisCommentCommandValidator()
+ {
+ RuleFor(x => x.TaskId).NotEmpty().WithMessage("TaskId is required");
+ RuleFor(x => x.Content)
+ .NotEmpty().WithMessage("Description cannot be empty")
+ .MaximumLength(5000).WithMessage("Description cannot exceed 5000 characters");
+ }
+ }
+}
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/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs
similarity index 57%
rename from Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs
index 5ebced69..b82a25ae 100644
--- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteComment/DeleteCommentCommandHandler.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/DeleteComment/DeleteCommentCommandHandler.cs
@@ -1,27 +1,29 @@
+using MediatR;
+using Planora.BuildingBlocks.Application.Context;
using Planora.BuildingBlocks.Domain;
using Planora.BuildingBlocks.Domain.Exceptions;
-using Planora.BuildingBlocks.Application.Context;
-using Planora.Todo.Domain.Repositories;
+using Planora.Collaboration.Application.Services;
+using Planora.Collaboration.Domain.Repositories;
-namespace Planora.Todo.Application.Features.Todos.Commands.DeleteComment
+namespace Planora.Collaboration.Application.Features.Comments.Commands.DeleteComment
{
public sealed class DeleteCommentCommandHandler : IRequestHandler
{
- private readonly ITodoCommentRepository _commentRepository;
- private readonly ITodoRepository _todoRepository;
+ private readonly ICommentRepository _commentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserContext _currentUserContext;
+ private readonly ITaskAccessService _taskAccessService;
public DeleteCommentCommandHandler(
- ITodoCommentRepository commentRepository,
- ITodoRepository todoRepository,
+ ICommentRepository commentRepository,
IUnitOfWork unitOfWork,
- ICurrentUserContext currentUserContext)
+ ICurrentUserContext currentUserContext,
+ ITaskAccessService taskAccessService)
{
_commentRepository = commentRepository;
- _todoRepository = todoRepository;
_unitOfWork = unitOfWork;
_currentUserContext = currentUserContext;
+ _taskAccessService = taskAccessService;
}
public async Task Handle(DeleteCommentCommand request, CancellationToken cancellationToken)
@@ -29,24 +31,25 @@ public async Task Handle(DeleteCommentCommand request, CancellationToken
var userId = _currentUserContext.UserId;
var comment = await _commentRepository.GetByIdAsync(request.CommentId, cancellationToken)
- ?? throw new EntityNotFoundException("TodoItemComment", request.CommentId);
+ ?? throw new EntityNotFoundException("Comment", 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.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 isTodoOwner = todoItem.UserId == userId;
+ var isTaskOwner = access.OwnerId == userId;
- if (comment.IsGenesisComment && !isTodoOwner)
+ if (comment.IsGenesisComment && !isTaskOwner)
throw new ForbiddenException("Only the task owner can delete the description");
- if (!isAuthor && !isTodoOwner)
+ if (!isAuthor && !isTaskOwner)
throw new ForbiddenException("Only the comment author or task owner can delete this comment");
comment.MarkAsDeleted(userId);
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/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs
similarity index 58%
rename from Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs
index 679a93a3..2d0184cb 100644
--- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandHandler.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandHandler.cs
@@ -1,53 +1,59 @@
+using MediatR;
+using Planora.BuildingBlocks.Application.Context;
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;
+using Planora.Collaboration.Application.DTOs;
+using Planora.Collaboration.Application.Services;
+using Planora.Collaboration.Domain.Repositories;
-namespace Planora.Todo.Application.Features.Todos.Commands.UpdateComment
+namespace Planora.Collaboration.Application.Features.Comments.Commands.UpdateComment
{
- public sealed class UpdateCommentCommandHandler : IRequestHandler>
+ public sealed class UpdateCommentCommandHandler : IRequestHandler>
{
- private readonly ITodoCommentRepository _commentRepository;
- private readonly ITodoRepository _todoRepository;
+ private readonly ICommentRepository _commentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserContext _currentUserContext;
+ private readonly ITaskAccessService _taskAccessService;
private readonly IUserService _userService;
public UpdateCommentCommandHandler(
- ITodoCommentRepository commentRepository,
- ITodoRepository todoRepository,
+ ICommentRepository commentRepository,
IUnitOfWork unitOfWork,
ICurrentUserContext currentUserContext,
+ ITaskAccessService taskAccessService,
IUserService userService)
{
_commentRepository = commentRepository;
- _todoRepository = todoRepository;
_unitOfWork = unitOfWork;
_currentUserContext = currentUserContext;
+ _taskAccessService = taskAccessService;
_userService = userService;
}
- public async Task> Handle(UpdateCommentCommand request, CancellationToken cancellationToken)
+ 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);
+ ?? throw new EntityNotFoundException("Comment", request.CommentId);
- if (comment.TodoItemId != request.TodoId)
- throw new EntityNotFoundException("TodoItemComment", 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 todoItem = await _todoRepository.GetByIdWithIncludesAsync(request.TodoId, cancellationToken)
- ?? throw new EntityNotFoundException("TodoItem", request.TodoId);
-
- if (todoItem.UserId != userId)
+ 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
{
@@ -57,9 +63,6 @@ public async Task> Handle(UpdateCommentCommand request, C
_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)
{
@@ -67,9 +70,9 @@ public async Task> Handle(UpdateCommentCommand request, C
avatars.TryGetValue(resolvedAuthorId, out authorAvatarUrl);
}
- return Result.Success(new TodoCommentDto(
+ return Result.Success(new CommentDto(
comment.Id,
- comment.TodoItemId,
+ comment.TaskId,
comment.AuthorId,
comment.AuthorName,
authorAvatarUrl,
diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandValidator.cs
similarity index 73%
rename from Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandValidator.cs
index dd107ff8..198e0efd 100644
--- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateComment/UpdateCommentCommandValidator.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Commands/UpdateComment/UpdateCommentCommandValidator.cs
@@ -1,9 +1,10 @@
-namespace Planora.Todo.Application.Features.Todos.Commands.UpdateComment
+namespace Planora.Collaboration.Application.Features.Comments.Commands.UpdateComment
{
public sealed class UpdateCommentCommandValidator : AbstractValidator
{
public UpdateCommentCommandValidator()
{
+ RuleFor(x => x.TaskId).NotEmpty().WithMessage("TaskId is required");
RuleFor(x => x.CommentId).NotEmpty().WithMessage("CommentId is required");
RuleFor(x => x.Content)
.NotEmpty().WithMessage("Content cannot be empty")
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/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs
similarity index 53%
rename from Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs
index 4dae17d4..03f09bcb 100644
--- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetComments/GetCommentsQueryHandler.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/Comments/Queries/GetComments/GetCommentsQueryHandler.cs
@@ -3,74 +3,63 @@
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;
+using Planora.Collaboration.Application.DTOs;
+using Planora.Collaboration.Application.Services;
+using Planora.Collaboration.Domain.Repositories;
-namespace Planora.Todo.Application.Features.Todos.Queries.GetComments
+namespace Planora.Collaboration.Application.Features.Comments.Queries.GetComments
{
public sealed class GetCommentsQueryHandler
- : IQueryHandler>>
+ : IQueryHandler>>
{
- private readonly ITodoRepository _todoRepository;
- private readonly ITodoCommentRepository _commentRepository;
+ private readonly ICommentRepository _commentRepository;
private readonly ICurrentUserContext _currentUserContext;
- private readonly IFriendshipService _friendshipService;
+ private readonly ITaskAccessService _taskAccessService;
private readonly IUserService _userService;
public GetCommentsQueryHandler(
- ITodoRepository todoRepository,
- ITodoCommentRepository commentRepository,
+ ICommentRepository commentRepository,
ICurrentUserContext currentUserContext,
- IFriendshipService friendshipService,
+ ITaskAccessService taskAccessService,
IUserService userService)
{
- _todoRepository = todoRepository;
_commentRepository = commentRepository;
_currentUserContext = currentUserContext;
- _friendshipService = friendshipService;
+ _taskAccessService = taskAccessService;
_userService = userService;
}
- public async Task>> Handle(
+ public async Task>> Handle(
GetCommentsQuery request, CancellationToken cancellationToken)
{
var userId = _currentUserContext.UserId;
if (userId == Guid.Empty)
- return Result>.Failure(
+ 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)
+ 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.GetPagedByTodoIdAsync(
- request.TodoId, request.PageNumber, request.PageSize, cancellationToken);
+ var (items, totalCount) = await _commentRepository.GetPagedByTaskIdAsync(
+ request.TaskId, 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.
+ // 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 (todoItem.UserId).
+ // task owner (access.OwnerId).
var authorIds = new HashSet();
foreach (var c in items)
{
if (c.IsGenesisComment)
{
- authorIds.Add(todoItem.UserId);
+ if (access.OwnerId != Guid.Empty)
+ authorIds.Add(access.OwnerId);
}
else if (!c.IsSystemComment && c.AuthorId != Guid.Empty)
{
@@ -87,16 +76,16 @@ public async Task>> Handle(
string? avatarUrl;
if (c.IsGenesisComment)
{
- avatars.TryGetValue(todoItem.UserId, out avatarUrl);
+ avatars.TryGetValue(access.OwnerId, out avatarUrl);
}
else
{
avatars.TryGetValue(c.AuthorId, out avatarUrl);
}
- return new TodoCommentDto(
+ return new CommentDto(
c.Id,
- c.TodoItemId,
+ c.TaskId,
c.AuthorId,
c.AuthorName,
avatarUrl,
@@ -109,8 +98,8 @@ public async Task>> Handle(
IsGenesisComment: c.IsGenesisComment);
}).ToList();
- return Result>.Success(
- new PagedResult(dtos, request.PageNumber, request.PageSize, totalCount));
+ 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/TodoApi/Planora.Todo.Application/Services/IUserService.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs
similarity index 78%
rename from Services/TodoApi/Planora.Todo.Application/Services/IUserService.cs
rename to Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs
index 43d0ff8a..c8063416 100644
--- a/Services/TodoApi/Planora.Todo.Application/Services/IUserService.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Application/Services/IUserService.cs
@@ -1,8 +1,8 @@
-namespace Planora.Todo.Application.Services
+namespace Planora.Collaboration.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.
+ /// 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
{
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/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs
similarity index 78%
rename from Services/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs
rename to Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs
index 83a63240..3a71adb7 100644
--- a/Services/TodoApi/Planora.Todo.Infrastructure/Services/CachingUserService.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/CachingUserService.cs
@@ -1,19 +1,18 @@
using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Logging;
-using Planora.Todo.Application.Services;
+using Planora.Collaboration.Application.Services;
-namespace Planora.Todo.Infrastructure.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.
+ /// 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 = "todo:user-avatar:";
+ private const string KeyPrefix = "collaboration:user-avatar:";
private readonly IUserService _inner;
private readonly IMemoryCache _cache;
@@ -78,12 +77,9 @@ public async Task> GetUserAvatarsAsync(
}
}
- if (missing.Count > 0)
- {
- _logger.LogDebug(
- "Avatar cache miss: {MissCount}/{TotalCount} fetched from Auth gRPC",
- missing.Count, ids.Count);
- }
+ _logger.LogDebug(
+ "Avatar cache miss: {MissCount}/{TotalCount} fetched from Auth gRPC",
+ missing.Count, ids.Count);
return result;
}
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..0bd1fbe4
--- /dev/null
+++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/TaskAccessGrpcClient.cs
@@ -0,0 +1,70 @@
+using Grpc.Core;
+using Planora.Collaboration.Application.Exceptions;
+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 (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 unavailable while checking comment access for task {TaskId}, requester {RequesterId}: Status={Status}",
+ taskId, requesterId, ex.StatusCode);
+ throw new ExternalServiceUnavailableException("TodoApi", "CheckTaskCommentAccess", ex);
+ }
+ }
+ }
+}
+
diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs
similarity index 94%
rename from Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs
rename to Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs
index ceab64b0..0151f598 100644
--- a/Services/TodoApi/Planora.Todo.Infrastructure/Services/UserGrpcService.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Grpc/UserGrpcService.cs
@@ -1,9 +1,8 @@
using Grpc.Core;
+using Planora.Collaboration.Application.Services;
using Planora.GrpcContracts;
-using Planora.Todo.Application.Services;
-using Microsoft.Extensions.Logging;
-namespace Planora.Todo.Infrastructure.Services
+namespace Planora.Collaboration.Infrastructure.Grpc
{
public sealed class UserGrpcService : IUserService
{
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/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs
similarity index 50%
rename from Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs
rename to Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs
index a68c6431..9194ebd2 100644
--- a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemCommentConfiguration.cs
+++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Configurations/CommentConfiguration.cs
@@ -1,14 +1,14 @@
-namespace Planora.Todo.Infrastructure.Persistence.Configurations
+namespace Planora.Collaboration.Infrastructure.Persistence.Configurations
{
- public sealed class TodoItemCommentConfiguration : IEntityTypeConfiguration
+ public sealed class CommentConfiguration : IEntityTypeConfiguration
{
- public void Configure(EntityTypeBuilder builder)
+ public void Configure(EntityTypeBuilder builder)
{
- builder.ToTable("todo_item_comments");
+ builder.ToTable("comments");
builder.HasKey(x => x.Id);
- builder.Property(x => x.TodoItemId).IsRequired();
+ builder.Property(x => x.TaskId).IsRequired();
builder.Property(x => x.AuthorId).IsRequired();
builder.Property(x => x.AuthorName)
@@ -32,16 +32,15 @@ public void Configure(EntityTypeBuilder builder)
.IsRequired()
.HasDefaultValue(false);
- builder.HasIndex(x => new { x.TodoItemId, x.CreatedAt });
- // T4.2 — FK on AuthorId lacked an index. "Comments authored by X"
- // queries (audit views, moderation, account-deletion cascade scan)
- // would otherwise seq-scan the table once a thread accumulates.
- builder.HasIndex(x => x.AuthorId);
+ // Optimised for timeline reads (ordered by creation per task).
+ builder.HasIndex(x => new { x.TaskId, x.CreatedAt });
- builder.HasOne()
- .WithMany()
- .HasForeignKey(x => x.TodoItemId)
- .OnDelete(DeleteBehavior.Cascade);
+ // Index on AuthorId (carried over from develop T4.2): "comments authored by X"
+ // queries — the UserDeleted cascade cleanup and moderation/audit views — would
+ // otherwise seq-scan the table once a thread accumulates. Collaboration owns no
+ // TodoItem aggregate, so there is no foreign key to the task (INV-OWN-1) — the
+ // task↔comment link lives only as the TaskId value.
+ builder.HasIndex(x => x.AuthorId);
}
}
}
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/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.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/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/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/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/DeleteTodo/DeleteTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs
index 6f276b73..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,6 +1,9 @@
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 +12,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 +43,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/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.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/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