Files
Justin Maier fdc1437da1 fix(users): scrub name, profile and links on account deletion; stop persisting the OAuth name (#4970)
* fix(users): scrub name, profile and links on account deletion; stop persisting the OAuth name

Account deletion is a soft delete, so no FK cascade fires. On prod, of 1,330,849
deleted accounts, 733,857 still carried `name` and 192,791 a UserProfile row.

- apps/auth: stop writing User.name on OAuth signup. It is unverified,
  user-controlled data that outlives a soft delete. It still seeds the
  generated username from the transient profile.
- deleteUser: also null `name` and delete the UserProfile row and every
  UserLink row, inside the transaction.
- Move the paddleCustomerId purge out of the transaction into a `finally`
  after the subscription cancels. cancelSubscriptionPlan falls back to
  reading it, so while it was nulled in the transaction that fallback could
  never fire on a deletion. The `finally` keeps the purge unskippable when
  an earlier unwrapped await throws.

customerId is deliberately NOT purged here: deleteUser's own
cancelSubscription triggers a Stripe webhook that resolves the user by
customerId and throws before deleting the CustomerSubscription row, so
nulling it would leave the row `active` forever. It is purged by the GDPR
scrub, which must reach Stripe first. Pinned by a test named for it.

Staff accounts created after this change file NCMEC reports without a
reporter firstName; the live report path reads `name` for nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(users): keep paddleCustomerId in the deletion transaction; harden the scrub tests

Reverts the paddleCustomerId ordering change from the previous commit. Moving
the null after the subscription cancels let cancelSubscriptionPlan's no-row
fallback run, but with seven live Paddle subscriptions (none on a deleted
account) it only added a live Paddle API call per deletion, a false
cancel-paddle-subscription error for nearly every one, and an unbounded wait
on a client with no timeout. paddleCustomerId is nulled inside the
transaction again, exactly as on main, and the try/finally that existed only
to protect that later null is removed, restoring main's tail.

deleteUser's net change is now only the GDPR scrub: null `name` and delete the
UserProfile and UserLink rows inside the transaction.

Tests, from the five-lane review:
- the customerId scan serialises BigInt instead of falling back to
  String(call), which turned an object into "[object Object]" and reported a
  false absence; a CONTROL pins it
- the scan's uncovered write paths are listed (kyselyWrite,
  updateManyAndReturn), alongside pgDbWrite and interactive transactions
- tests that only made sense for the reverted ordering are removed, and one
  pins paddleCustomerId inside the transaction

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(users): pin the soft delete inside the transaction; cover interactive transactions

From the test-lane re-review of #4970:

- Nothing asserted that the soft-delete user.update is itself one of the
  $transaction ops. Awaiting it outside the array passed every test, including
  the ones named "inside the transaction". Both the transaction test and the
  paddleCustomerId test now assert its identity in the ops array.
- A test-local $transaction override returned its argument unrun, which hid
  customerId writes made inside an interactive transaction. The shared mock
  runs the callback, so the override is removed and a CONTROL proves the scan
  now sees that route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:16:12 -06:00
..