Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Planora.BuildingBlocks.Application.Messaging.Events
{
/// <summary>
/// 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.
/// </summary>
public static class TaskActivityType
{
public const string Completed = "Completed";
public const string StartedWorking = "StartedWorking";
public const string Left = "Left";
}

/// <summary>
/// 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).
/// </summary>
public sealed class TaskActivityIntegrationEvent : IntegrationEvent
{
public Guid TaskId { get; init; }
public Guid ActorId { get; init; }
public string ActorName { get; init; } = string.Empty;

/// <summary>One of <see cref="TaskActivityType"/>.</summary>
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Planora.BuildingBlocks.Application.Messaging.Events
{
/// <summary>
/// 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 ("ветка").
/// </summary>
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Planora.BuildingBlocks.Application.Messaging.Events
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions GrpcContracts/Protos/todo.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions Planora.ApiGateway/ocelot.Docker.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions Planora.ApiGateway/ocelot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions Planora.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading