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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user