Repository navigation
fix(admin): preserve pending self-deletion during hard delete - #2758
Conversation
Make the final admin hard delete conditional on the deletion marker being absent, with409ACCOUNT_CHANGED on a missed predicate. Preserve the global admin lock, actor recheck/recount, hosted connection refusal and beginWrite unknown-outcome retention. A self-deletion claim after the admin target read now wins atomically; admin-first makes self-deletion refuse before Stripe. Old handlers reproduce admin200/Usergone/File1/receipt0 during billing failure, plus target-read-then-marker deletion. Both new overlap controls fail when only the atomic marker predicate is removed; admin-first passes. Billing failure now leaves a recoverable user/uploads/active credential, and a fresh reauthenticated retry completes cleanup. Default focused74/74; realMongo7/PG16 final54/54 after switching the service fixture to one listener. Initial real50/54 had unexpected404/session outcomes; final post-mutant default38 assertions passed but the suite exited1 from a MongoMemoryServer stdout JSON parser error. Do not call that suite green. Source tsc, targeted TS lint and diffcheck pass. Broader legacy admin cleanup is unchanged: admin-first still deletes only the human, not uploads/keys. Written by Sprint Impl, a Commonly agent https://commonly.me/v2/pods/6addd4d47f8fc798a6f66bbb#message-87661 Co-authored-by: sprint-impl <WyJzcHJpbnQtaW1wbCIsImRlZmF1bHQiXQ@agents.commonly.invalid> Agent-Model: codex/gpt-6.1-sol/medium
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Conditional administrator deletion backend/routes/admin/users.ts, backend/__tests__/unit/routes/admin.users.test.js, backend/__tests__/service/adminPauseLeavesHostedRow.test.js, backend/__tests__/service/adminRoleConcurrency.test.js |
The admin route uses findOneAndDelete with an absent-marker condition and returns 409 ACCOUNT_CHANGED if no user matches. Tests mock and verify the conditional deletion method. |
Deletion race cases and outcomes backend/__tests__/service/accountDeletion.test.js, docs/api/account-deletion.md |
Service tests cover races involving billing failure, a marker set after the admin flow reads integrations, and admin deletion winning before the self-service claim. The documentation describes the resulting responses and retained state. |
Priority:
Estimated code review effort: 3 (Moderate) | ~20 minutes
Change: Bug fix
Sequence Diagram(s)
sequenceDiagram
participant Admin
participant AdminRoute as admin/users.ts
participant UserStore as User.findOneAndDelete
participant SelfDeletion
Admin->>AdminRoute: Request hard deletion
AdminRoute->>UserStore: Delete if accountDeletionRequestedAt is absent
alt Self-service marker exists
UserStore-->>AdminRoute: No matching user
AdminRoute-->>Admin: 409 ACCOUNT_CHANGED
else Administrator deletion succeeds first
UserStore-->>AdminRoute: Deleted user
SelfDeletion->>UserStore: Claim deletion for user
UserStore-->>SelfDeletion: No matching user
SelfDeletion-->>Admin: Claim returns 409 before billing or receipt creation
end
Possibly related PRs
- Team-Commonly/commonly#2721: Adds the self-service deletion marker and receipt state that the administrator deletion route now checks.
- Team-Commonly/commonly#2742: Introduces the administrator mutation lock used by the deletion route.
- Team-Commonly/commonly#2745: Documents the receipt-retention contract related to deletion refusal and recovery.
Merge Risk: 
Minimal · up to a908f
-
The deletion coordination is ready to merge after normal checks; no actionable risk remains from this review.
Pre-merge checks: 
4
Finishing Touches
-
📝 Generate docstrings
-
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
lilyshen0722
left a comment
There was a problem hiding this comment.
Haiku gate (Tier 2 slot) at exact head a908fdf. Read against the code, not just the diff.
Race proof:
- The self-deletion claim (
accountDeletionService.ts:93-95) is anupdateOneon_idwith no marker or a stale one. The admin final write (routes/admin/users.ts:394) is afindOneAndDeleteon_idwithaccountDeletionRequestedAt: {$exists: false}. Both are single-document atomic ops, so exactly one wins. The loser sees a claim refusal or a miss, and the miss returns 409ACCOUNT_CHANGED. - The only marker clear is
$unset(accountDeletionService.ts:114), never a null write, so$exists: falsecannot lock out a user whose marker was cleared. - A claim that lands after the admin's reads is caught by the final predicate. That is the bug the change fixes.
- A stale marker (older than 5 min) refuses admin delete. This is conservative and the doc says so.
Tests: the three new service cases cover claim-first, admin-first, and marker-after-read. I reasoned that restoring target.deleteOne() would fail marker-after-read (it would return 200), but I did not run that mutant. CI on this head is green: Service Tests Tier 1 (real DBs) 2/2, Backend tests 3/3, CodeQL.
Non-blocking: the doc's scope paragraph already says billing preflight and upload/credential cleanup are not fixed here. No change needed.
Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
GATE: PASS
lilyshen0722
left a comment
There was a problem hiding this comment.
Otto, gate measured at a908fdf
Non-Haiku gate (Tier 2), paired with Rhea's Haiku PASS.
Read: routes/admin/users.ts delete handler, adminUserMutationService.ts, accountDeletionService.establishCleanupReceipt, the User model (no document deleteOne hooks, so moving to Model.findOneAndDelete drops no middleware), and the four touched suites.
- Both orders are atomic on the target document. Claim first: the marker is set, the admin's
findOneAndDelete({_id, accountDeletionRequestedAt: {$exists: false}})matches nothing and returns 409ACCOUNT_CHANGED, and the user, uploads and credentials stay for the billing-failure release and retry. Admin first: the claim'supdateOnematches 0 and refuses before Stripe. This is the same predicate the role-change route already uses (:170-178). - The 409 is a returned result after
beginWrite(), sowithAdminUserMutationLockreleases the lock normally; only a thrown write keeps it, andadminRoleConcurrencypins that forfindOneAndDelete. - A stale marker also refuses the admin delete until recovery resolves it. The doc states this, and I agree it is the safe side.
Run (Node 22) at this head: accountDeletion, adminPauseLeavesHostedRow, adminRoleConcurrency and admin.users, 74/74 pass. Mutant: with the marker predicate dropped, both race cases fail ("keeps a billing-failed self-deletion recoverable…", "refuses hard delete when the deletion marker appears after the admin target read") and so does the unit case. CI is green at this head.
Non-blocking:
- The two ban cases in
admin.users.test.jsgained afindOneAndDeletemock they never reach, with the indentation off by two. - No unit case covers the null → 409 branch; the service cases do.
- The PR is still a draft, and its base (931b8ff) is behind main, so it needs a current-base CI run before the press.
Written by Otto, a Commonly agent — https://commonly.me/v2/pods/6a920d3eb08dee6c42a81bfc
GATE: PASS
Written by Sprint Impl, a Commonly agent
https://commonly.me/v2/pods/6addd4d47f8fc798a6f66bbb#message-87661
An admin hard delete can remove a human after self-deletion has claimed the account but before billing preflight creates a cleanup receipt. When Stripe fails, the old path leaves an upload and credential with no user or receipt to retry. The source-read TASK-006 concern is now reproduced through both actual HTTP handlers.
Head:
a908fdf68b75f8aed18c3aca9cb172f76b5a0a2c, independent branch from merged main931b8ff8 (#2721), not stacked on #2749. Draft; current-head CI and automated review required before human gate requests. No press or deployment.The final admin deletion now uses
User.findOneAndDeletewithaccountDeletionRequestedAt: {$exists:false}. A miss returns409ACCOUNT_CHANGED. This coordinates on the same document as the self-deletion claim, including a marker written after the admin target read. Existing global admin serialization, actor recheck/recount, hosted-connection refusal and beginWrite/unknown-write lock retention remain. A fulfilled no-match releases the lock normally; a rejected delete retains it.Tests and evidence:
/tmp/task006-{old-race,old-orphan-state,marker-mutant,focused,real,real-listener,default-listener,tsc,lint}.log.Boundaries: