{"record":{"id":"cfdb504720ae11dc","repo":"fullstackhero/dotnet-starter-kit","slug":"only-the-author-or-a-moderator-can-delete","errorCode":null,"errorMessage":"Only the author or a moderator can delete.","messagePattern":"Only the author or a moderator can delete\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/Modules/Chat/Modules.Chat/Domain/Message.cs","lineNumber":102,"sourceCode":"        }\n        if (!string.Equals(AuthorUserId, editingUserId, StringComparison.Ordinal))\n        {\n            throw new InvalidOperationException(\"Only the author can edit a message.\");\n        }\n        ArgumentException.ThrowIfNullOrWhiteSpace(newBody);\n\n        Body = newBody.Trim();\n        EditedAtUtc = DateTime.UtcNow;\n        AddDomainEvent(DomainEvent.Create((id, ts) =>\n            new MessageEditedDomainEvent(ChannelId, Id, AuthorUserId, id, ts)));\n    }\n\n    public void SoftDelete(string deletingUserId, bool isModerator)\n    {\n        if (DeletedAtUtc.HasValue) return;\n        if (!isModerator && !string.Equals(AuthorUserId, deletingUserId, StringComparison.Ordinal))\n        {\n            throw new InvalidOperationException(\"Only the author or a moderator can delete.\");\n        }\n        DeletedAtUtc = DateTime.UtcNow;\n        Body = null;\n        AddDomainEvent(DomainEvent.Create((id, ts) =>\n            new MessageDeletedDomainEvent(ChannelId, Id, AuthorUserId, id, ts)));\n    }\n\n    public MessageAttachment AddAttachment(Guid? fileAssetId, string url, string contentType, string fileName, long sizeBytes)\n    {\n        var attachment = MessageAttachment.Create(Id, fileAssetId, url, contentType, fileName, sizeBytes);\n        _attachments.Add(attachment);\n        return attachment;\n    }\n\n    /// <summary>\n    /// Toggle-on a reaction. Returns the new <see cref=\"MessageReaction\"/>, or <c>null</c> if the\n    /// (user, emoji) pair already exists — the unique index would reject the duplicate row.\n    /// </summary>","sourceCodeStart":84,"sourceCodeEnd":120,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Chat/Modules.Chat/Domain/Message.cs#L84-L120","documentation":"Message.SoftDelete permits deletion only by the message's author or by a moderator (isModerator == true); anyone else throws InvalidOperationException. Note it is idempotent for already-deleted messages (early return). This is the ownership/moderation guard for message removal.","triggerScenarios":"Calling message.SoftDelete(deletingUserId, isModerator: false) where deletingUserId differs from AuthorUserId — e.g. a regular user trying to delete another user's message, or a handler failing to pass the caller's moderator flag.","commonSituations":"Moderator role claim not mapped to the isModerator parameter (always false); missing endpoint authorization so non-owners reach the aggregate; client UI showing delete buttons on others' messages; users attempting to delete messages in channels they moderate but where isModerator wasn't computed.","solutions":["Pass the caller's actual moderator status (from claims/permissions) into SoftDelete, not a hardcoded false.","Check authorship or moderator rights before calling SoftDelete and return 403 with a clear message otherwise.","Catch InvalidOperationException in the handler and translate to 403 Forbidden instead of 500.","Centralize the permission check (e.g. a MustBeAuthorOrModerator policy) so all delete paths compute isModerator consistently."],"exampleFix":"// before\nmessage.SoftDelete(currentUserId, isModerator: false);\n// after\nvar isModerator = user.HasPermission(ChatPermissions.DeleteAnyMessage);\nif (!isModerator && message.AuthorUserId != currentUserId)\n{\n    throw new ForbiddenAccessException(\"Only the author or a moderator can delete.\");\n}\nmessage.SoftDelete(currentUserId, isModerator);","handlingStrategy":"validation","validationCode":"public static bool CanSoftDelete(Domain.Message m, string userId, bool isModerator) => m.DeletedAtUtc is null && (isModerator || string.Equals(m.AuthorUserId, userId, StringComparison.Ordinal));","typeGuard":"if (!isModerator && message.AuthorUserId != currentUserId) throw new ForbiddenAccessException(\"Only the author or a moderator can delete.\");","tryCatchPattern":"try { message.SoftDelete(userId, isModerator); } catch (InvalidOperationException ex) when (ex.Message.Contains(\"author or a moderator\")) { throw new ForbiddenAccessException(ex.Message); }","preventionTips":["Compute isModerator from claims/permissions on every delete path — never hardcode false","Gate the delete endpoint with a permission check as well as the domain guard","Rely on SoftDelete's early return for idempotent deletes","Show delete buttons only for own messages or to users with moderation permission"],"tags":["domain","chat","delete","authorization","moderation"],"backgroundTag":"permission-denied","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"}