Skip to content
bitzorcas
中EN

Concept

Comments

A source-verified manual for generic resource keys, comment threads, idempotent creation, editing, deletion, tenant boundaries, and legacy Ticket migration.

Last updated

Comments attaches a thread to any business resource through (EntityType, EntityId) and forms replies through ParentCommentId. The current implementation provides tenant-scoped storage, four generic permissions, a client-supplied CommentId idempotency key, a 30-minute author edit window, soft-deleted placeholders, and migration from legacy Ticket.CommentsJson.

1. The boundary the module actually owns

Comments owns:

  • the generic SysComment support row;
  • the CommentId idempotency key and parent reference;
  • body, author, edit time, and soft-deletion state;
  • the thread-tree DTO and deleted placeholders;
  • author and hard-coded admin-role rules for edit/delete;
  • one-time migration from Ticket.CommentsJson to SysComment.

It does not currently own subject existence or access decisions, moderation, mention parsing, notifications, spam prevention, attachments, reactions, search, pagination, events, retention policy, or GDPR erasure orchestration.

2. Current end-to-end structure

No current policy call

Authenticated client

Generated /api/comments endpoint

Generic comments.comment.* authorization

Create / Get / Update / Delete

ICommentStore

SysComment
CommentThreadSupportRecord

CommentThreadNode tree

Subject owner
Documents / Tickets / ...

Contracts contains the public DTOs. Application owns the permission catalog, requests, handlers, and store port. Infrastructure implements that port with the provider-neutral IEntitySet<CommentThreadSupportRecord>. The module marker declares only an Authorization dependency, yet Application references Tickets.Contracts and Infrastructure directly references Tickets.Infrastructure for legacy migration. That is inconsistent with both the governance declaration and any claim of narrow-port isolation.

3. HTTP surface

Method and routeMessagePermission actionCurrent rule
POST /api/comments/CreateComment.CommandCreatenon-empty inputs, Body ≤ 10,000, idempotency, valid parent
GET /api/comments?entityType=&entityId=GetComments.QueryViewload current-tenant rows including deleted, build tree in memory
PUT /api/comments/{id}UpdateComment.CommandUpdateauthor within 30 minutes or admin role
DELETE /api/comments/{id}DeleteComment.CommandDeleteauthor or admin; clear body and soft-delete

Update selects StandardCommand timeout. Create, Delete, and Get do not select an explicit timeout. None of the four routes declares a rate-limit policy. The {id} route value is the persistence Id, not the client-supplied CommentId; SDKs must retain the distinction.

The exact permission strings are comments.comment.view/create/update/delete. They can decide who may invoke Comments, but every ResourceDescriptor is only (comments, comment): it contains neither EntityType nor EntityId and cannot answer who may access ticket-42.

4. Data model

Business subject
EntityType + EntityId

Root comment
ParentCommentId=null

Reply
ParentCommentId=root.CommentId

Soft-deleted placeholder
Body=null

SysComment is a tenant- and soft-delete-aware table:

FieldLimitMeaning
CommentId36client idempotency key; may reuse the old TicketComment id
EntityType50free-form client string; there is no subject registry
EntityId36target resource id; no owner validation is performed
ParentCommentId36, nullablethe parent CommentId within the subject
AuthorId36UserId string; Create writes "0" when no user exists
Body10,000, nullablebounded on create/update; cleared on deletion
EditedAtnullablelast edit time; deletion actor/time/reason are not stored

The unique index is (TenantId, EntityType, EntityId, CommentId). Additional indexes cover EntityType+EntityId, ParentCommentId, and AuthorId without an explicit TenantId prefix; validate real execution plans before relying on them for a large multi-tenant table.

5. Creating a root comment or reply

Create a root comment
POST /api/comments/ HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"commentId": "019c64f8-55a7-7d7f-9da7-b60f53bcf9e7",
"entityType": "Document",
"entityId": "doc-42",
"parentCommentId": null,
"body": "Step two needs a rollback example."
}

The caller should generate CommentId once and reuse it on transport retries. The handler first looks up Tenant + Subject + CommentId and returns an existing row as success. A parent lookup includes the same TenantId, EntityType, and EntityId, so the normal HTTP path cannot create a cross-subject reply.

Reuse CommentId while retrying
export async function createComment(
subject: { type: string; id: string },
body: string,
parentCommentId?: string,
): Promise<CommentNode> {
// Generate once before the first attempt; a timeout retry keeps this value.
const commentId = crypto.randomUUID();
const payload = {
commentId,
entityType: subject.type,
entityId: subject.id,
parentCommentId: parentCommentId ?? null,
body,
};
for (let attempt = 1; attempt <= 2; attempt++) {
const response = await fetch("/api/comments/", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
// Replay transport/server failures only; validation and authorization need action.
if (response.ok) return response.json() as Promise<CommentNode>;
if (response.status < 500 || attempt === 2)
throw new Error(`comment create failed: ${response.status}`);
}
throw new Error("unreachable");
}

This is not the framework-wide IIdempotentRequest facility. Two first requests can both miss before insert; the unique index is the last guard, but the handler does not translate the concurrent unique violation into the existing result. Normal lookup also hides soft-deleted rows while the unique index can retain their key, so replay after deletion can become a database conflict.

6. Thread-query semantics

GetComments intentionally calls ListIncludingDeletedAsync so a deleted node can still hold visible replies. Its projection rules are:

  • a null ParentCommentId makes a root;
  • an orphan whose parent is absent is promoted to a root;
  • children are ordered by CreateTime only;
  • a deleted node has Body=null and IsDeleted=true but keeps Replies;
  • a Create response has an empty Replies collection; fetch the thread to get its complete shape.

The query has no pagination, record cap, or depth limit. It loads the entire subject and recurses. A very deep chain can exhaust the stack, an imported cycle can recurse forever, and duplicate CommentId data makes ToDictionary fail. The normal unique index does not replace validation after imports or manual repair.

7. Most important current gaps

  1. EntityType/EntityId are free input, without a subject registry or owner policy.
  2. Documents.AllowComments, Ticket visibility/status, and equivalent owner rules are not enforced.
  3. Create/Get pass User.TenantId while the store also requires EffectiveTenantId; impersonation can cause empty reads or broken idempotency.
  4. Create does not require a user and falls back to AuthorId=0.
  5. Moderator behavior depends on the role string admin, not a capability decision.
  6. Handler validation does not cover identifier lengths declared by persistence metadata.
  7. Idempotency does not normalize concurrent insert or define replay after soft deletion.
  8. Threads have no paging, depth/reply limits, cycle guard, or deterministic tie-breaker.
  9. There are no create/edit/delete events, mentions, notifications, moderation records, or dedicated audit data.
  10. There is no feature catalog entry, so plans or tenants cannot switch Comments off through the feature system.

8. Reading path

  1. Thread lifecycle, idempotency, editing, and deletion
  2. Subject authorization, tenancy, and privacy
  3. Legacy Ticket-comment migration
  4. Testing, operations, and GA gates

Related chapters: Documents, Authorization, Multitenancy, and Auditing.

9. Source checks

Terminal window
# Routes, actions, and the generic permission catalog.
rg -n 'GenerateEndpoint|AuthorizationAction|PermissionDefinition' \
src/Platform/Comments -g '*.cs'
# Subject policies, AllowComments, events, and notifications: expect no Comments hits.
rg -n 'EnsureCanComment|AllowComments|CommentCreated|Notification|Mention' \
src/Platform/Comments -g '*.cs'
# Divergent tenant sources and the hard-coded administrator role.
rg -n 'User.TenantId|EffectiveTenantId|AdminRole|\?\? "0"' \
src/Platform/Comments -g '*.cs'

Back to module catalog

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%