Skip to content
bitzorcas
中EN

Guide

Comments thread lifecycle, idempotency, editing, and deletion

A detailed account of CommentId, parent-child threads, full-tree projection, the 30-minute edit window, admin bypass, soft-deleted placeholders, and concurrency boundaries.

Last updated

Four Application handlers coordinate the comment lifecycle; there is no comment domain aggregate. Rules are split across Create, Update, Delete, GetComments, and CommentStore, so a review must cover handler code, database indexes, and soft-delete query behavior together.

1. Two identifiers with different jobs

IdentifierProducerPurpose
Idpersistence entity/frameworkUpdate and Delete route parameter
CommentIdclient or legacy Ticket dataidempotency key and ParentCommentId reference

Do not send CommentId as /api/comments/{id}. An SDK that retains only CommentId cannot invoke the current update/delete routes. CommentThreadNode returns both identifiers for this reason.

2. Creation state machine

POSTsame tenant + subject +CommentIdreturn stored nodenew CommentIdparent missing or deletedinsert rowauthor within 30 min oradminauthor or adminauthor or adminrepeated delete returns notfound

Validating

Existing

Returned

ParentCheck

Rejected

Active

Edited

Deleted

Create validates five required inputs and the body limit. It does not validate the persistence limits for EntityType, EntityId, CommentId, and ParentCommentId. An overlong identifier therefore fails at the provider boundary and may produce provider-specific error semantics.

Body length uses .NET string.Length: UTF-16 code units, not grapheme clusters, UTF-8 bytes, or rendered characters. A UI counter must use the same contract or the API must define a different explicit character rule.

3. Exact idempotency scope

The current key is:

TenantId + EntityType + EntityId + CommentId

The same CommentId may identify a comment on another EntityId. Repeating a key within one subject returns the first row; a new Body or ParentCommentId is silently ignored. This is result replay, not an idempotency-conflict check.

Keep the idempotent payload stable in a server-side consumer
var command = new CreateComment.Command(
CommentId: operationId,
EntityType: "Ticket",
EntityId: ticketId,
ParentCommentId: parentId,
Body: normalizedBody);
// A retry must reuse the complete payload. The current service does not compare
// the stored Body/ParentCommentId with a later request that reuses operationId.
var first = await mediator.Send(command, cancellationToken);
var replay = await mediator.Send(command, cancellationToken);
// Both successes should identify one persistence row, not two nodes.
first.Value!.Id.ShouldBe(replay.Value!.Id);

Two concurrent first requests can both miss before Insert. The unique index prevents two committed rows, but the handler does not catch the unique violation and reload the winner. A production implementation should normalize that race to success and compare a request fingerprint so the same key cannot silently accept different content.

4. Parent-comment rules

The parent lookup includes TenantId, EntityType, EntityId, and ParentCommentId, which keeps a normal reply in the same subject. When a parent is soft-deleted, GetByCommentId is normally filtered by soft deletion and returns no row. The code therefore reaches Comment.ParentNotFound; Comment.ParentDeleted is only reachable if a store returns a deleted row, which the current implementation does not.

The source exposes both errors, but they do not have equal reachability. There is also no maximum reply depth, per-node reply count, or thread total. Clients should not render unbounded recursion; a production API needs explicit paging and depth contracts.

5. Edit rules

Update loads an undeleted row by persistence Id and EffectiveTenantId. It then allows either:

  • AuthorId equal to the current UserId string; or
  • a role equal to admin, case-insensitively.

For a non-admin, UtcNow - CreateTime > 30 minutes is rejected, so exactly 30 minutes remains allowed. Admin bypasses the window. A successful update trims Body and stores EditedAt.

Handle an edit-window rejection at the API client
using var response = await api.PutAsJsonAsync(
$"/api/comments/{comment.Id}",
new { body = editedBody },
cancellationToken);
// Forbidden can mean non-author or expired window; neither is safe to retry.
if (response.StatusCode == HttpStatusCode.Forbidden)
throw new CommentEditRejectedException(
await response.Content.ReadAsStringAsync(cancellationToken));
// NotFound also covers another effective tenant and a soft-deleted record.
response.EnsureSuccessStatusCode();

The role-name bypass is outside the Authorization permission catalog. A safer design uses a comments.comment.moderate capability or a subject-aware policy and audits the operator, reason, subject, and original author.

6. Deletion and structural placeholders

Delete has no 30-minute limit. An author or admin can invoke it; the store sets IsDeleted=true and Body=null while retaining identifiers, parent linkage, author, and timestamps.

Deleted root
Body=null

Visible reply A

Visible reply B

A repeated Delete calls the normal GetById, so soft-delete filtering turns it into NotFound rather than idempotent success. No DeletedAt, DeletedBy, or Reason is stored; the current table cannot establish who deleted content or when.

7. How the tree is built

GetComments loads every row for the subject, including deleted rows, then:

  1. creates a dictionary keyed by CommentId;
  2. creates a lookup keyed by ParentCommentId;
  3. treats null-parent and missing-parent rows as roots;
  4. recursively builds Replies;
  5. orders each level by CreateTime.

Equal CreateTime values have no secondary key, so ordering can vary by provider. There is no visited set, and bad data containing a cycle recurses without termination. Reading all rows also lets one hot subject amplify memory and response size.

Target tree builder with cycle protection
CommentThreadNode Build(CommentRecord current, HashSet<string> path)
{
// Track the current recursion path; re-entry proves that imported data cycles.
if (!path.Add(current.CommentId))
throw new InvalidOperationException("comment cycle detected");
var replies = children[current.CommentId]
.OrderBy(x => x.CreateTime)
.ThenBy(x => x.CommentId, StringComparer.Ordinal)
.Select(child => Build(child, new HashSet<string>(path)))
.ToList();
// Preserve the structural node but never project a deleted body.
return ToNode(current, replies);
}

At scale, prefer cursor-paged roots, a bounded number of replies per root, and an endpoint that pages children by ParentCommentId.

8. Output safety and privacy

The service trims strings; it does not sanitize Markdown or HTML. JSON serialization usually encodes transport safely, but a UI that renders raw HTML/Markdown must use an allowlist sanitizer and reject scripts, event attributes, and dangerous URL schemes.

Clearing Body reduces exposure in the primary table, but database logs, backups, audit records, search indexes, and notification copies may still retain it. Comments has no cross-system erasure orchestration, so Body=null is not evidence of complete erasure.

9. Error-to-client behavior

ErrorConditionClient action
Comment.InvalidInputrequired create field is absentcorrect input
Comment.BodyTooLongCreate/Update exceeds 10,000shorten body
Comment.EmptyBodyUpdate body is emptycorrect input
Comment.ParentNotFoundparent lookup returns no rowrefresh thread
Comment.ParentDeletedstore returns a deleted parentnormally unreachable with current store
Comment.NotAuthorneither author nor admindo not retry
Comment.EditWindowExpirednon-admin is later than 30 minutesadd a follow-up or use moderation workflow
Comment.NotFoundid missing, another tenant, or deletedrefresh/hide action

10. Required test cases

  • same key/same payload, same key/different payload, and concurrent first requests;
  • replaying CommentId after soft deletion;
  • active, deleted, cross-subject, and cross-tenant parents;
  • exactly 30 minutes, future CreateTime, and admin casing;
  • client callers, null UserId, and AuthorId=0;
  • Unicode at the 10,000-code-unit boundary;
  • deep chains, cycles, orphans, duplicate CommentId, and equal CreateTime;
  • proving Body is absent from ordinary queries, include-deleted projections, and responses after deletion.

Back to Comments

100%

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