Base URL for the browser/frontend is the API Gateway:
http://localhost:5132
Route evidence:
Planora.ApiGateway/ocelot.jsonPlanora.ApiGateway/ocelot.Docker.json- service controllers under
Services/*/Planora.*.Api/Controllers
Protected routes require:
Authorization: Bearer <access-token>Auth state-changing browser routes also require CSRF:
X-CSRF-Token: <value from XSRF-TOKEN cookie>The frontend sends CSRF headers for all state-changing API calls, but backend CSRF validation is implemented in the Auth API pipeline.
The codebase uses a few response shapes:
| Shape | Example | Code |
|---|---|---|
| raw DTO/object | login/register/user endpoints | individual controllers |
Result<T> wrapper |
some category/friendship/todo paths | ResultToActionResultFilter, controller return code |
PagedResult<T> |
todos, users, friends, login history, messages | BuildingBlocks/Planora.BuildingBlocks.Application/Pagination/PagedResult.cs |
| error envelope | exception middleware failures | BuildingBlocks/Planora.BuildingBlocks.Domain/ApiResponse.cs |
PagedResult<T> fields:
{
"items": [],
"pageNumber": 1,
"pageSize": 10,
"totalCount": 0,
"totalPages": 0,
"hasPreviousPage": false,
"hasNextPage": false
}Frontend unwrapping code:
frontend/src/lib/api.ts:parseApiResponsefrontend/src/types/category.ts:toCategoryList
Service-level rate limiting policies are configured in BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Extensions/ServiceCollectionExtensions.cs.
| Policy | Limit | Applied |
|---|---|---|
register |
3 requests/minute/IP | POST /auth/api/v1/auth/register |
login |
5 requests/minute/IP | POST /auth/api/v1/auth/login |
auth |
10 requests/minute/IP | refresh/logout/validate-token/password reset |
data |
50 requests/minute/IP | configured but no controller usage found in inspected routes |
Ocelot route files leave most routes unthrottled, but the realtime route enables RateLimitOptions with a 100 requests/minute window. Gateway Program.cs registers an ASP.NET Core global limiter (100 requests/minute/IP, fixed window) and now applies it via app.UseRateLimiter() ahead of authentication and Ocelot, so every gateway request is throttled per client IP with a 429 Too Many Requests + Retry-After: 60 response — closing the gap where unthrottled Ocelot routes had no rate limit at all.
| Gateway route | Downstream service | Auth |
|---|---|---|
GET /health |
gateway | public |
GET /auth/health |
Auth API | public |
GET /todos/health |
Todo API | public |
GET /categories/health |
Category API | public |
GET /messaging/health |
Messaging API | public |
GET /collaboration/health |
Collaboration API | public |
GET /realtime/health |
Realtime API | public |
/auth/api/v1/auth/{everything} |
Auth AuthenticationController |
mixed |
/auth/api/v1/users/{everything} |
Auth UsersController |
bearer at gateway; VerifyEmailByToken is [AllowAnonymous] in the service controller |
/auth/api/v1/friendships* |
Auth FriendshipsController |
bearer |
/friendships* |
Auth FriendshipsController legacy route |
bearer |
/auth/api/v1/analytics/{everything} |
Auth AnalyticsController |
bearer |
/todos/api/v1/{everything} |
Todo API | bearer |
/categories/api/v1/{everything} |
Category API | bearer |
/messaging/api/v1/{everything} |
Messaging API | bearer |
/collaboration/api/v1/{everything} |
Collaboration API (task comment timeline) | bearer |
/realtime/api/v1/{everything} |
Realtime API HTTP route (notifications + connections REST) | bearer |
/realtime/{everything} |
Realtime API websocket route (SignalR hub /hubs/notifications) |
route-dependent |
Controller: Services/AuthApi/Planora.Auth.Api/Controllers/AuthenticationController.cs
Public. Issues the double-submit CSRF token.
Response:
{
"token": "<base64-random-token>",
"expiresIn": 3600
}Side effect: sets readable XSRF-TOKEN cookie with SameSite=Strict, Path=/, one-hour expiry.
Public, CSRF, rate limit register.
Body:
{
"email": "user@example.com",
"password": "StrongPass123!",
"confirmPassword": "StrongPass123!",
"firstName": "Jane",
"lastName": "Doe"
}Validation:
- email required, valid, max 255;
- password required, 8-128, uppercase, lowercase, digit, special char;
- confirmation must match;
- first and last name required, max 100, letters/spaces/hyphen/apostrophe.
Success 200: access token and user fields. Refresh token is set only as httpOnly cookie and omitted from JSON.
Errors:
400validation or command failure;409duplicate/already-existing user.
Public, CSRF, rate limit login.
Body:
{
"email": "user@example.com",
"password": "StrongPass123!",
"rememberMe": true,
"twoFactorCode": "123456"
}twoFactorCode is optional but must be 6 characters when present.
Success 200: access token, user fields, expiry, twoFactorEnabled. Refresh token is set as httpOnly cookie. If rememberMe is false, the cookie is session-only.
Error: 401 for failed login.
Public, CSRF, rate limit auth.
Reads refresh_token from the httpOnly cookie. No JSON body is required.
Success 200:
{
"accessToken": "<jwt>",
"expiresAt": "2026-05-03T12:00:00Z",
"tokenType": "Bearer",
"rememberMe": true
}Side effect: rotates the refresh cookie.
Errors:
204 No Contentif the refresh cookie is absent;400,401, or404depending on refresh-token failure.
Bearer, CSRF, rate limit auth.
Body may be empty. Controller also accepts legacy body with refresh token, but current frontend relies on cookie.
Success 200:
{ "message": "Logged out successfully" }Side effect: always deletes refresh_token cookie.
Public, CSRF, rate limit auth.
Token can be provided through Authorization: Bearer <token> or legacy body:
{ "token": "<jwt>" }Returns TokenValidationDto from Services/AuthApi/Planora.Auth.Application/Features/Authentication/Response/TokenValidationDto.cs.
Public, CSRF, rate limit auth.
Body:
{ "email": "user@example.com" }Success is intentionally generic:
{ "message": "If the email exists, a password reset link has been sent." }Public, CSRF, rate limit auth.
Body:
{
"resetToken": "<token>",
"newPassword": "NewStrongPass123!",
"confirmPassword": "NewStrongPass123!"
}Success:
{ "message": "Password has been reset successfully" }Controller: Services/AuthApi/Planora.Auth.Api/Controllers/UsersController.cs
Canonical prefix: /auth/api/v1/users
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/me |
bearer | current user profile |
PUT |
/me |
bearer + CSRF | update profile |
DELETE |
/me |
bearer + CSRF | delete account |
POST |
/me/change-password |
bearer + CSRF | change password |
POST |
/me/change-email |
bearer + CSRF | request email change |
GET |
/verify-email?token=... |
public | verify email by token |
POST |
/me/verify-email |
bearer + CSRF | send/resend verification link; legacy body token also verifies |
GET |
/me/security |
bearer | security summary |
POST |
/me/2fa/enable |
bearer + CSRF | start TOTP setup |
POST |
/me/2fa/confirm |
bearer + CSRF | confirm TOTP — returns 10 single-use recovery codes |
POST |
/me/2fa/disable |
bearer + CSRF | disable TOTP |
GET |
/me/sessions |
bearer | list sessions |
DELETE |
/me/sessions/{tokenId} |
bearer + CSRF | revoke session |
POST |
/me/sessions/revoke-all |
bearer + CSRF | revoke all sessions |
GET |
/me/login-history?pageNumber=&pageSize= |
bearer | login history |
POST |
/me/avatar |
bearer + CSRF + multipart/form-data |
upload profile avatar |
GET |
/statistics |
admin | user statistics |
GET |
/ |
admin | paged users |
GET |
/{userId} |
admin | user detail |
POST /auth/api/v1/users/me/avatar accepts a single file field as multipart/form-data.
| Limit | Value | Enforced by |
|---|---|---|
| Max body size | 6 MB (5 MB payload + multipart overhead) | [RequestSizeLimit] on the action |
| Max image bytes | 5 MB | UploadAvatarCommandValidator + ImageSharpImageProcessor |
| Allowed MIME | image/jpeg, image/png, image/webp |
content-type whitelist + magic-byte sniff |
| Min dimensions | 64×64 | ImageSharp decoder check |
| Max dimensions | 4096×4096 | ImageSharp decoder check |
| Output format | always image/webp (re-encoded server-side, lossy q=85) |
ImageSharpImageProcessor |
| Metadata stripping | EXIF / ICC / XMP cleared before re-encode | ImageSharpImageProcessor |
Error codes:
| HTTP | Error code | Cause |
|---|---|---|
400 |
INVALID_IMAGE_CONTENT |
File is not a decodable image, or fails min-dimension check |
413 |
INVALID_FILE_SIZE |
Payload exceeds 5 MB |
415 |
UNSUPPORTED_MEDIA_TYPE |
MIME or magic bytes outside JPEG/PNG/WEBP whitelist |
401 |
NOT_AUTHENTICATED |
Missing/invalid bearer token |
404 |
USER_NOT_FOUND |
Authenticated user record was deleted |
Success returns UserDto with profilePictureUrl pointing at the canonical (medium, 128px) WebP variant. Three variants are persisted for every upload — the URL scheme is /avatars/{userId:N}/{contentHash}/{size}.webp where size ∈ {64, 128, 512}. Clients build other variant URLs by swapping the size segment.
The path is content-addressed: changing the avatar produces a new hash subdirectory, and the previous one is pruned. This makes Cache-Control: public, max-age=31536000, immutable safe for /avatars/* — the URL itself changes when the bytes change. Static-file serving is configured in Services/AuthApi/Planora.Auth.Api/Program.cs with X-Content-Type-Options: nosniff and ServeUnknownFileTypes = false.
GET /me and admin user detail responses include isEmailVerified and emailVerifiedAt. isEmailVerified is the direct boolean status; emailVerifiedAt is present when the verification timestamp is known.
Profile update body:
{
"firstName": "Jane",
"lastName": "Doe",
"profilePictureUrl": "https://example.com/avatar.png"
}Delete/revoke-all/disable-2FA bodies require password. Confirm 2FA body requires code.
Confirm 2FA success response shape:
{
"message": "Two-factor authentication enabled successfully",
"recoveryCodes": [
"ABCDE-12345",
"FGHIJ-67890"
]
}The recoveryCodes array contains exactly 10 codes formatted XXXXX-XXXXX. Each code is single-use and can be entered in place of a TOTP code at login. Store them securely — they are only returned once. A new set replaces all previous codes on every re-confirmation.
Controller: Services/AuthApi/Planora.Auth.Api/Controllers/FriendshipsController.cs
Canonical prefix: /auth/api/v1/friendships
Legacy prefix also routed by the gateway: /friendships
| Method | Path | Purpose |
|---|---|---|
POST |
/requests |
send request by user id |
POST |
/requests/by-email |
send request by email |
POST |
/requests/{friendshipId}/accept |
accept incoming request |
POST |
/requests/{friendshipId}/reject |
reject incoming request |
DELETE |
/{friendId} |
remove friend |
GET |
?pageNumber=1&pageSize=10 |
list friends |
GET |
/requests?incoming=true |
list incoming or outgoing requests |
GET |
/friend-ids?userId=<guid> |
internal friend id helper |
GET |
/are-friends?userId1=<guid>&userId2=<guid> |
internal friendship helper |
Send by id:
{ "friendId": "00000000-0000-0000-0000-000000000000" }Send by email:
{ "email": "friend@example.com" }The by-email response is generic by design.
Controller: Services/AuthApi/Planora.Auth.Api/Controllers/AnalyticsController.cs
Bearer + CSRF.
Body:
{
"eventName": "SESSION_RESTORED",
"properties": {
"source": "frontend"
},
"occurredAt": "2026-05-03T12:00:00Z"
}Rules:
eventNameis required and must be allowlisted byBusinessEvents.IsAllowedProductEvent.propertiesmust be a JSON object when present.- serialized properties must be <= 4096 bytes.
- the frontend only dispatches analytics when it has an access token, because the endpoint is authenticated.
Success: 202 Accepted.
Errors:
400 EVENT_NAME_REQUIRED400 UNKNOWN_ANALYTICS_EVENT400 INVALID_PROPERTIES400 PROPERTIES_TOO_LARGE
Controller: Services/CategoryApi/Planora.Category.Api/Controllers/CategoriesController.cs
Gateway prefix: /categories/api/v1/categories
All routes require bearer auth. State-changing frontend calls include CSRF header, although CSRF validation was only found in Auth API.
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
list current user's categories |
POST |
/ |
create category |
PUT |
/{id} |
update category |
DELETE |
/{id} |
delete category |
Create body:
{
"userId": null,
"name": "Work",
"description": "Work tasks",
"color": "#007BFF",
"icon": "Briefcase",
"displayOrder": 0
}The controller overwrites userId with current user context by sending UserId = null to the handler.
Validation:
namerequired, max 50;descriptionmax 500;colormust be a predefined color or#plus six alphanumeric characters.
DELETE returns 204, 404, 403, or 400 depending on handler result.
Controller: Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs
Gateway prefix: /todos/api/v1/todos
All routes require bearer auth.
| Method | Path | Purpose |
|---|---|---|
GET |
?pageNumber=1&pageSize=10&status=&categoryId=&isCompleted=&completedFrom=&completedTo= |
list own and friend-visible todos |
GET |
/public?pageNumber=1&pageSize=10&friendId= |
list public or directly shared friend todos |
GET |
/{id} |
get one todo |
POST |
/ |
create todo |
PUT |
/{id} |
update todo |
DELETE |
/{id} |
delete todo |
PATCH |
/{id}/hidden |
owner hidden toggle |
PATCH |
/{id}/viewer-preferences |
non-owner viewer hidden/category/completion preference (viewer reopen allowed unless author completed globally — see below) |
POST |
/{id}/join |
join task as a worker |
POST |
/{id}/leave |
leave task (stop being a worker) |
POST |
/{id}/duplicate |
duplicate a task into a fresh active copy (any participant) |
GET |
/{id}/subtasks |
list a task's subtasks (anyone with parent access) |
POST |
/{id}/subtasks |
create a subtask (owner only; category/visibility inherited) |
Comments (the task timeline) moved to the Collaboration service — see the Collaboration section. The old
/{id}/comments*and/{id}/genesisroutes under/todos/api/v1/todosno longer exist.
completedFrom / completedTo are an optional, inclusive completion-date window (ISO 8601
instants) used by the completed archive's "find a task by roughly when it was finished" search. Each
bound is normalized to UTC server-side and compared against CompletedAt; either may stand alone
(open-ended on the missing side). A task with no CompletedAt is excluded the moment either bound is
set. The bounds combine with isCompleted=true and the other filters. The frontend sends the local
day edges (start-of-day → end-of-day) so a single calendar day matches every task finished that day
regardless of the stored time-of-day.
Subtask reads are enriched with the author's live identity: GET /{id}/subtasks resolves
authorName + authorAvatarUrl for each subtask from Auth (GetUserProfilesBatch, one batch call
per request, failure-tolerant — labels are simply empty if Auth is down), and
POST /{id}/subtasks fills them from the caller's own JWT claims (the creator is the caller).
Both fields are null on list endpoints that skip the enrichment (e.g. the dashboard task lists).
Create body:
{
"userId": null,
"title": "Pay bills",
"description": "Electricity and internet",
"categoryId": "00000000-0000-0000-0000-000000000000",
"dueDate": "2026-05-10T12:00:00Z",
"dueDateStart": "2026-05-08T12:00:00Z",
"expectedDate": "2026-05-09T12:00:00Z",
"priority": "Medium",
"isPublic": false,
"sharedWithUserIds": [],
"requiredWorkers": 3
}dueDate is the estimated-completion date — a single target date, or the later bound (deadline) of an interval. dueDateStart is the optional earlier bound: omit it (or send null) for a single date; when present it must be ≤ dueDate.
Update body fields are optional:
{
"title": "Updated title",
"description": "Updated description",
"categoryId": null,
"dueDate": "2026-05-10T12:00:00Z",
"dueDateStart": "2026-05-08T12:00:00Z",
"clearDueDate": false,
"expectedDate": null,
"actualDate": null,
"priority": "High",
"isPublic": true,
"sharedWithUserIds": ["00000000-0000-0000-0000-000000000000"],
"status": "InProgress",
"requiredWorkers": 3,
"clearRequiredWorkers": false
}Rules:
- title required on create, max 200 for a regular task; subtask titles allow up to 1500 (a subtask's whole content lives in its title — see
POST /todos/{id}/subtasks). The shared update endpoint (PUT /todos/{id}) also accepts up to 1500 because subtask renames go through it; - description optional, max 2000 (validators and the EF column agree);
- expected date cannot be after due date;
dueDateStart(interval start) requiresdueDateto be set and must be≤ dueDate; the later bound is always the deadline. On update, a baredueDate: nullmeans unchanged (the full-payload autosave always sends it) — sendclearDueDate: trueto actually remove the date/interval, mirroringclearRequiredWorkers;- category must belong to current user;
- shared users must be accepted friends;
isPublicis independent fromsharedWithUserIds; public tasks are visible to all accepted friends, direct shares are visible to the selected accepted friends;- non-owner friend-visible viewer can only change
status; - backend statuses are
Todo,InProgress,Done; parser also accepts aliases; requiredWorkersmust be ≥ 1 when set; for non-public tasks it cannot exceed1 + sharedWith.Count;- set
clearRequiredWorkers: trueto remove the capacity limit on update.
TodoItemDto worker fields:
{
"requiredWorkers": 3,
"workerCount": 1,
"isWorking": true,
"workerUserIds": ["00000000-0000-0000-0000-000000000000"]
}TodoItemDto subtask aggregate:
{
"openSubtaskCount": 2
}openSubtaskCountis the number of this task's subtasks that are still open (notDone, not deleted).0when the task has no subtasks or every subtask is finished. It is computed with a single grouped query and surfaced by the list endpoint (GET /todos) and the detail endpoint (GET /todos/{id}); other list endpoints that skip the enrichment return0. Always0for a subtask (subtasks have no children). The frontend uses it to warn before finishing a task that still has unfinished subtasks.
Hidden toggle body:
{ "hidden": true }Viewer preference body (non-owner only; the owner gets OWNER_MUST_USE_HIDDEN_ENDPOINT):
{
"hiddenByViewer": true,
"viewerCategoryId": "00000000-0000-0000-0000-000000000000",
"updateViewerCategory": true,
"completedByViewer": true
}completedByViewer: truemarks the shared/public task done for this viewer only (writesUserTodoViewPreference.CompletedByViewer; never touches the owner'sTodoItem).completedByViewer: false(reopen) is allowed — a viewer may return their own completion to active — unless the author has completed the whole task globally (Status == Done). In that case the task is closed for everyone and the request fails with{ "code": "AUTHOR_ALREADY_COMPLETED" }("Автор уже отметил задачу выполненной …"); the viewer's path on an author-closed task isPOST /{id}/duplicate. The same rule is enforced on thePUT /todos/{id}status path (a non-owner sendingstatus: "todo"), so neither route can bypass it.- The response DTO carries
ownerCompleted— the author's real completion truth (the entity's globalStatus == Done), independent of any per-viewer completion. Clients use it to show the correct reopen affordance and avoid sending a request the server will reject.
Join the task as a worker. Requires friendship with the task owner and access to the task (public or shared). Owner cannot join their own task. Fails if already a worker or at capacity.
Success 200: updated TodoItemDto with isWorking: true.
Errors: 400 for duplicate join, capacity full, or owner attempting to join; 403 for non-friend or no access; 404 if task not found.
Leave a task. Fails if not currently a worker or if the user is the task owner.
Success 204 No Content.
Errors: 400 for owner or non-worker; 404 if task not found.
Duplicate a task into a brand-new active task owned by the caller. Open to any participant —
the owner, or a friend who can see the task (public or directly shared) — so a non-owner can fork a
completed task instead of reopening it (returning a task to work is author-only). The server authors
the copy and copies the task's content — title, description, priority, category (re-validated against
the duplicator; dropped if not theirs or since-deleted), visibility (isPublic), shared audience
(re-validated against the duplicator's current friendships — others dropped), tags, and
requiredWorkers. It deliberately does not copy the dates (dueDate/expectedDate), the
completion state (the copy starts active), or the branch (comments / subtasks). The copy emits the
same TaskCreatedIntegrationEvent a normal create does, so the new branch's "created" system comment
and all participant notifications fire.
No request body. Success 201 Created: the new TodoItemDto (with category info populated). Errors:
403 if the caller cannot access the task (not the owner and not a friend who can see a public/shared
task); 404 if the task does not exist or is a subtask (subtasks have no standalone existence to
duplicate); 503 if the Category/Auth gRPC checks are unavailable.
Hidden shared/public todos may return a redacted TodoItemDto; see features.md.
Gateway prefix: /collaboration/api/v1/comments. All routes require bearer auth.
The Collaboration service owns the task comment timeline. It does not own tasks:
every route authorises against the task via the TodoService.CheckTaskCommentAccess gRPC call,
which applies the same owner / shared / public + friendship rule the Todo handlers used to.
The pinned "Author's Note" (the task description) is not stored here. It is the single
source of truth on the task (TodoItem.Description, owned by Todo) and is synthesised into the
timeline on read from the same CheckTaskCommentAccess call (so it appears instantly, always
matches the task card, and is present for tasks created before this service existed). Edit the
description via the task itself (PUT /todos/api/v1/todos/{id}), not through a comment endpoint.
| Method | Path | Purpose |
|---|---|---|
GET |
/{taskId}?pageNumber=1&pageSize=50 |
get paginated comments (oldest-first); page 1 also includes the synthesised Author's Note |
POST |
/{taskId} |
add a comment, optionally as a reply quoting another comment/reply or a subtask |
PUT |
/{taskId}/{commentId} |
edit a regular comment (author only) |
DELETE |
/{taskId}/{commentId} |
soft-delete a comment (author or task owner) |
Get paginated comments for a task. Access requires task visibility (public/shared) and friendship with the owner — enforced by the Todo gRPC access check. Default page size is 50, oldest-first.
Success 200: PagedResult<CommentDto>.
CommentDto shape (wire-compatible with the former TodoCommentDto — the todoItemId field name
is kept so frontend timeline components are unchanged):
{
"id": "00000000-0000-0000-0000-000000000000",
"todoItemId": "00000000-0000-0000-0000-000000000000",
"authorId": "00000000-0000-0000-0000-000000000000",
"authorName": "Alice",
"authorAvatarUrl": null,
"content": "Looks good!",
"createdAt": "2026-05-10T14:00:00Z",
"updatedAt": null,
"isOwn": true,
"isEdited": false,
"isSystemComment": false,
"isGenesisComment": false,
"replyToType": null,
"replyToId": null,
"replyToAuthorId": null,
"replyToAuthorName": null,
"replyToAuthorAvatarUrl": null,
"replyToPreview": null,
"replyToDeleted": false
}Reply block (replyTo* — all null/false on a plain comment): when the comment is a reply,
replyToType is "comment" (a user comment or another reply) or "subtask", replyToId is the
quoted target's id and replyToPreview is a one-line excerpt (≤ 300 chars) of the quoted text.
The quoted author (replyToAuthorId / replyToAuthorName / replyToAuthorAvatarUrl) is resolved
live from Auth on every read — the stored name is only a fallback. For comment targets the
preview is refreshed from the live target on read (edits propagate) and replyToDeleted flips to
true the moment the target is gone (the stored snapshot then backs the preview). For subtask
targets the preview is the title snapshot taken at reply time and replyToDeleted is maintained by
the SubtaskDeletedIntegrationEvent consumer. Reply chains are just replies whose target is itself
a reply — there is no nesting limit and no extra endpoint.
isOwn is true when authorId == currentUserId AND isSystemComment is false. isEdited is
true when updatedAt > createdAt + 5 seconds for a regular user comment; system comments
(including the synthesised genesis) never report isEdited.
System comments (isSystemComment: true) are materialised automatically from Todo task-lifecycle
integration events (created / completed / started / left). They have authorId = Guid.Empty,
authorName = "", isOwn = false. The genesis entry (isGenesisComment: true, only on page 1)
is the synthesised Author's Note: it is not stored — its content is the live task description,
its author is the task owner, and its id equals the task id. Author identity (name + avatar) for
both regular comments and the genesis is resolved live from Auth (GetUserProfilesBatch, 60 s
cache) — never a stored copy, so a profile rename is reflected everywhere.
Errors: 400 unauthenticated; 403 no access / non-friend; 404 task not found; 503 if the Todo
access check is unavailable.
Add a comment. Caller must have task access. Body:
{
"content": "Great progress!",
"replyTo": { "type": "comment", "id": "00000000-0000-0000-0000-000000000000" }
}content — required, max 2000 characters. replyTo — optional; when present the comment becomes a
reply quoting the target. type is "comment" (a user comment or another reply in the same
branch) or "subtask" (a subtask of this task). The target is validated server-side and the
quote snapshot (author + preview) is captured there — preview text from the client is never
accepted. Comment targets must live in the same task branch and may not be system events or the
genesis note; subtask targets are verified live via the TodoService.GetSubtaskBrief gRPC call
(exists, not deleted, child of this exact task).
Success 201 Created: CommentDto (with the populated reply block). On success a
NotificationEvent is fanned out (via outbox → RabbitMQ → Realtime/SignalR) to every other
participant; the quoted author receives a dedicated comment.reply notification ("… replied to your
message/subtask") instead of the generic comment.added. Errors: 400 validation / invalid reply
target type; 403 no access; 404 task, target comment, or target subtask not found (cross-branch
target ids return 404 exactly like missing ones — no probing oracle); 503 if the Todo
validation call is unavailable.
The task description (Author's Note) is edited on the task, not here. Use
PUT /todos/api/v1/todos/{id}with the newdescription(owner only). There is no genesis comment endpoint — the description is a single source of truth in Todo, synthesised into the timeline on read.
Edit a regular user comment. Only the author may edit it. Body: { "content": "Updated text" } —
required, max 2000 characters. Success 200: updated CommentDto (author name/avatar resolved
live). Errors: 400 wrong task scope / validation; 403 not author; 404 not found.
Soft-delete a comment. Allowed for the comment author or the task owner. Plain system comments
cannot be deleted (the Author's Note is cleared by editing the task description to empty). Success
204 No Content. Errors: 403 not allowed; 404 not found.
Controller: Services/MessagingApi/Planora.Messaging.Api/Controllers/MessagesController.cs
Gateway prefix: /messaging/api/v1/messages
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/ |
bearer | send message |
GET |
?otherUserId=&page=1&pageSize=20 |
bearer | get messages |
GET |
/health |
public at service route | service-local health helper |
Send body:
{
"senderId": null,
"subject": "Hello",
"body": "Message body",
"recipientId": "00000000-0000-0000-0000-000000000000"
}The controller overwrites sender with current user context by sending SenderId = null.
Validation:
recipientIdrequired;subjectrequired, max 200;bodyrequired, max 10000;pageSizemax 100;- explicit sender cannot equal recipient.
Success for send:
{
"messageId": "00000000-0000-0000-0000-000000000000",
"createdAt": "2026-05-03T12:00:00Z"
}Controllers:
Services/RealtimeApi/Planora.Realtime.Api/Controllers/ConnectionsController.csServices/RealtimeApi/Planora.Realtime.Api/Controllers/NotificationsController.cs
Gateway prefix for HTTP/websocket route: /realtime/{everything}
Service-local protected routes:
| Method | Service path | Gateway path | Auth | Purpose |
|---|---|---|---|---|
GET |
/api/v1/connections/active |
/realtime/api/v1/connections/active |
bearer | current user's active SignalR connections |
GET |
/api/v1/connections/stats |
/realtime/api/v1/connections/stats |
admin | total connection count |
GET |
/api/v1/notifications/summary |
/realtime/api/v1/notifications/summary |
bearer | unread total + per-task breakdown (card dots, branch badges, bell count) |
GET |
/api/v1/notifications |
/realtime/api/v1/notifications |
bearer | paged notification list, newest first (bell dropdown); ?take=&before= |
POST |
/api/v1/notifications/read |
/realtime/api/v1/notifications/read |
bearer | mark read by { all }, { taskId }, or { ids }; returns fresh summary |
POST |
/api/v1/notifications/send |
/realtime/api/v1/notifications/send |
admin | operator/diagnostic self-notify; admin-only, non-security types only |
POST |
/api/v1/notifications/broadcast |
/realtime/api/v1/notifications/broadcast |
admin | broadcast notification |
Every notification endpoint is scoped to the JWT subject server-side — a user can only ever read or
mark read their own notifications (no IDOR surface), and all reads are AsNoTracking.
GET /notifications/summary response (drives every inline indicator in one round trip):
{
"totalUnread": 4,
"perTask": [
{
"taskId": "11111111-1111-1111-1111-111111111111",
"count": 3,
"latestType": "task.review",
"groups": [
{ "type": "task.review", "count": 1, "latestOccurredOnUtc": "2026-06-24T12:05:00Z" },
{ "type": "comment.added", "count": 2, "latestOccurredOnUtc": "2026-06-24T12:01:00Z" }
]
}
]
}groups is the per-type breakdown that drives the card's notification badge cluster, ordered by
latestOccurredOnUtc descending (newest type first). count / latestType are retained for
backward compatibility — latestType === groups[0].type.
POST /notifications/read request (exactly one selector, priority all → taskId → ids):
{ "taskId": "11111111-1111-1111-1111-111111111111" }POST /notifications/send body (admin-only operator tool — the production path is the gRPC/bus
channel). type must be a non-security UI type (info, success, warning, error,
TodoCreated/TodoUpdated/TodoDeleted, FriendRequest, FriendAccepted); security types such as
PasswordChanged and AccountLocked are rejected with 400 INVALID_NOTIFICATION_TYPE so a client can
never spoof a security alert into a session:
{ "message": "Saved", "type": "info" }SignalR:
- Hub path inside service:
/hubs/notifications - Gateway path:
/realtime/hubs/notifications - JWT can be supplied as
access_tokenquery parameter for/hubspaths. - The hub multiplexes three streams over one socket:
ReceiveNotification(per-user notifications),TaskFeedChanged/BranchChanged(live data-sync), andUserTyping/UserStoppedTyping.
ReceiveNotification payload (the full persisted shape — the client renders the toast, lights the
right card/branch indicator and decides on an OS notification without a follow-up fetch):
{
"id": "…", "userId": "…", "taskId": "…", "actorId": "…",
"type": "task.review", "title": "Ready for review",
"message": "Everyone finished \"Launch plan\" — it's ready for your review",
"occurredOnUtc": "2026-06-16T09:00:00Z", "isRead": false
}Notification type discriminators (the actor is always excluded from recipients):
type |
Raised when | Recipients | OS notification |
|---|---|---|---|
comment.added |
new branch message | other participants | no |
comment.reply |
a reply targets you | the quoted author | yes |
subtask.added |
subtask created | other participants | no |
subtask.completed |
subtask marked done | other participants | no |
task.started |
someone takes the task into work | other participants | no |
task.completed |
a collaborator/owner completes a public/shared task | the others | yes |
task.review |
all participants (≠author) done and all subtasks done | author only | yes |
task.participants_done |
all participants (≠author) done but subtasks remain | author only | yes |
| URL | Expected response |
|---|---|
/health |
gateway health |
/auth/health |
Auth API health |
/todos/health |
Todo API health |
/categories/health |
Category API health |
/messaging/health |
Messaging API health |
/realtime/health |
Realtime API health |
Health routes are explicitly defined in Ocelot route files.
No unauthenticated public CRUD API for todos, categories, messages, users, or realtime notifications was found. Public unauthenticated gateway routes are limited to health checks, auth entry points, CSRF token, password reset initiation/reset, token validation, and registration/login. Email verification GET is [AllowAnonymous] in UsersController, but the committed Ocelot users route is bearer-protected.