{"record":{"id":"0260345f0eccdcb0","repo":"fullstackhero/dotnet-starter-kit","slug":"cannot-reply-to-a-reply-threads-are-single-level-only","errorCode":null,"errorMessage":"Cannot reply to a reply — threads are single-level only.","messagePattern":"Cannot reply to a reply — threads are single-level only\\.","errorType":"exception","errorClass":"CustomException","httpStatus":400,"severity":"warning","filePath":"src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs","lineNumber":54,"sourceCode":"            .ConfigureAwait(false)\n            ?? throw new NotFoundException(\"Channel not found.\");\n\n        channel.RequireMember(currentUserId);\n\n        Message? parent = null;\n        if (cmd.ParentMessageId is { } parentId)\n        {\n            parent = await db.Messages.FirstOrDefaultAsync(m => m.Id == parentId, cancellationToken)\n                .ConfigureAwait(false)\n                ?? throw new NotFoundException(\"Parent message not found.\");\n            if (parent.ChannelId != channel.Id)\n            {\n                throw new CustomException(\"Parent message belongs to a different channel.\", (IEnumerable<string>?)null, HttpStatusCode.BadRequest);\n            }\n            if (parent.ParentMessageId.HasValue)\n            {\n                // 1-level deep only per spec.\n                throw new CustomException(\"Cannot reply to a reply — threads are single-level only.\", (IEnumerable<string>?)null, HttpStatusCode.BadRequest);\n            }\n        }\n\n        // Parse @username tokens to user ids; self-mentions and unresolved tokens are dropped silently.\n        // Only resolved *other* users attach as MessageMention rows + fire an event. Body may be empty.\n        var rawMatches = MentionParser.Parse(cmd.Body ?? string.Empty);\n        var distinctNames = rawMatches.Select(m => m.Username)\n            .Distinct(StringComparer.OrdinalIgnoreCase)\n            .ToArray();\n        var resolved = distinctNames.Length == 0\n            ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n            : (Dictionary<string, string>)await mentionResolver\n                .ResolveUserIdsAsync(distinctNames, cancellationToken)\n                .ConfigureAwait(false);\n\n        var parsedMentions = new List<Message.ParsedMention>();\n        var notifyUserIds = new HashSet<string>(StringComparer.Ordinal);\n        foreach (var match in rawMatches)","sourceCodeStart":36,"sourceCodeEnd":72,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs#L36-L72","documentation":"Chat threads are one level deep: the handler rejects replying to a message that is itself a reply (parent.ParentMessageId.HasValue) with a 400 CustomException. Only top-level messages can be replied to.","triggerScenarios":"Passing a ParentMessageId that points to an already-nested reply — e.g. a UI that renders a 'reply' button on every message in a thread including child replies, or an API client walking a thread recursively.","commonSituations":"Threaded view rendering reply buttons on child messages, automation or tests using a child message id as parent, or a client model that doesn't distinguish parent vs reply messages.","solutions":["Only enable reply on messages where parentMessageId is null.","When a reply to a reply is attempted, retarget the request to the thread's root message id instead.","Render child replies with a 'View thread' action pointing to the root, not a reply action.","Validate parentId depth client-side before submitting."],"exampleFix":"// before\nconst target = msg.parentMessageId ?? msg.id;\n// after\n// retarget reply to the thread root (already the fix) or:\nif (msg.parentMessageId) disableReplyButton(msg);","handlingStrategy":"validation","validationCode":"if (parent?.parentMessageId) throw new Error('Threads are single-level; reply to the root message instead');","typeGuard":"const isThreadRoot = (m) => m.parentMessageId == null;\n// only show Reply when isThreadRoot(m)","tryCatchPattern":"try { await sendReply(cmd); }\ncatch (e) { if (e.status === 400 && /single-level/.test(e.message)) { retargetToRootAndRetry(); } else throw e; }","preventionTips":["Gate Reply UI on parentMessageId == null","Retarget replies to the thread root automatically","Document the one-level threading rule for API consumers"],"tags":["chat","threads","bad-request"],"backgroundTag":"invalid-state-transition","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}