fix(comment): dismiss owner/admin authz + atomic conditional delete + 404-only onError (#329 review)

Maintainer escalation decision (B) + reviewer findings on the ephemeral-
suggestion PR.

Authz (decision B): POST /comments/dismiss-suggestion now gates the destructive
branch on owner-OR-space-admin, mirroring POST /comments/delete exactly (same
SpaceCaslAction.Manage / SpaceCaslSubject.Settings, same owner short-circuit,
same ForbiddenException). A non-owner non-admin who tries to dismiss another's
childless suggestion gets Forbidden before the service runs. Apply stays on
canEdit (accepting an edit is the editor's semantics), unchanged.

F1 [blocking] — atomic conditional delete closes the hasChildren→delete race.
New repo `deleteCommentIfChildless(id)` runs a single
`DELETE FROM comments WHERE id=:id AND NOT EXISTS (SELECT 1 FROM comments child
WHERE child.parent_comment_id = comments.id)` (verified by compiling the Kysely
expression to SQL — the correlated subquery references the OUTER comments.id).
deleteEphemeralSuggestion strips the mark first, then the conditional delete: if
it removed the row → commentDeleted + outcome 'deleted'; if a reply raced in
(0 rows) → fall back to resolveComment (outcome 'resolved') so the discussion and
the new reply survive. No reply can be cascade-deleted anymore.

F2 [warning] — the apply/dismiss onError success-noop is narrowed from 404||400
to 404 ONLY. A 400 means the comment is ALIVE (apply's 400 = the thread was
resolved-not-applied), so it now shows a real error (surfacing the server
message) and KEEPS the comment in cache instead of a false "applied" + dropping a
live thread.

F3 [suggestion] — the 404-race client tests assert the success toast fired.

Tests: server — dismiss authz (owner ok / non-owner-non-admin Forbidden /
space-admin ok), the delete→resolve race (hasChildren=false but conditional
delete returns 0 → resolve, no commentDeleted), delete-path asserts switched to
deleteCommentIfChildless; client — apply-400 and dismiss-400 (kept in cache, red,
not success) + the toast assertions.

server tsc clean, comment+collaboration jest green; client tsc clean, comment
vitest 54 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-04 18:22:35 +03:00
parent 8d8ecaed82
commit e6d8eda8e5
8 changed files with 357 additions and 44 deletions
@@ -139,6 +139,37 @@ export class CommentRepo {
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
}
/**
* Atomic conditional delete for an ephemeral suggestion (#338 / #329 F1):
* delete the row ONLY if it is still childless, in a single statement, and
* return the number of rows removed (0 or 1). This closes a race: dismiss/apply
* read `hasChildren`, then remove the anchor mark (a collab round-trip of
* tens-to-hundreds of ms), then delete. `comments.parent_comment_id` is
* ON DELETE CASCADE, so if a reply lands in that window an unconditional delete
* would cascade-delete the just-added reply forever. The `NOT EXISTS` re-checks
* childlessness at delete time inside the same statement, so an interleaved
* reply makes this delete 0 rows and the caller can fall back to resolving the
* thread instead of destroying the discussion.
*/
async deleteCommentIfChildless(commentId: string): Promise<number> {
const result = await this.db
.deleteFrom('comments')
.where('id', '=', commentId)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('comments as child')
.select('child.id')
.whereRef('child.parentCommentId', '=', 'comments.id'),
),
),
)
.executeTakeFirst();
return Number(result?.numDeletedRows ?? 0n);
}
async hasChildren(commentId: string): Promise<boolean> {
const result = await this.db
.selectFrom('comments')