Skip to content

fix(privacy): delete-my-data scrubs the requester identity off site suggestions - #227

Open
BSalaeddin wants to merge 1 commit into
feat/site-suggestions-status-and-requester-noticefrom
feat/site-suggestions-delete-my-data-scrub
Open

BSalaeddin wants to merge 1 commit into
feat/site-suggestions-status-and-requester-noticefrom
feat/site-suggestions-delete-my-data-scrub

Conversation

@BSalaeddin

Copy link
Copy Markdown
Collaborator

Third in the stack: #225 (capture) -> #226 (answer + notice) -> this. Base is feat/site-suggestions-status-and-requester-notice, not dev. Closes the TODO #225 left in the SiteSuggestion model doc.

The bug

"Delete my data" emptied three tables and left the requester's email address sitting in site_suggestions.

Scrub, not delete

The row's identifying half is nulled — user_id, requester_email, user_agent. domain, raw_url, status and created_at stay, and nothing new is stamped.

A "please support this store" request is not personal data once the requester is off it, and it is the coupons pipeline's input. Deleting the row would silently retract a store request other people may also have made, and would corrupt a queue this user does not own. A scrub is also not an ANSWER to the request, so it dates nothing: status_changed_at, notified_at and imported_at are untouched.

Matched two ways, and the second is the point

where: { OR: [
  { userId },
  { requesterEmail: { equals: email, mode: 'insensitive' } },   // only when the account has an email
]}

user_id finds the requests made while signed in. The requester email finds the ones made while signed OUT — where the row carries no user id at all and the address the person typed is the only thing on it. Matching by user_id alone is the fix that looks right and leaves behind exactly the email this route exists to remove; that losing predicate is kept verbatim in the suite and run against the same rows.

Case-insensitive, because the suggest form records what the visitor typed (Shopper@Example.COM) while the account holds its own spelling — a case-sensitive compare walks straight past the row.

An account with no email (the schema allows one) contributes no email branch rather than a null one, which would match every anonymous suggestion ever made.

In the same transaction

The scrub is the fourth member of the existing $transaction batch, not a loose await after it. Run afterwards, a failure would leave the three tables emptied and the email still in site_suggestions, with a 500 telling the user nothing had been removed.

The response reports it under its own key — scrubbed: { siteSuggestions: n }, never folded into deleted, because these rows still exist and calling that a deletion would be a lie the next reader believes. The profile client reads only res.ok, so the added key changes no UI.

Verification

gate result
tsc --noEmit clean
app unit 781 passed (was 770)
extension unit 894 passed
integration (real Postgres) 40 passed (was 38)
eslint / oxlint / knip / turbo run prettier-check clean

Red-proofed in three arms:

arm result
match on user_id alone reds the shipped-pair pin, the where-clause pin, and the real-Postgres pin
scrub moved out of the transaction reds exactly the 3 batch-shape pins
deleted instead of scrubbed reds 7 pins

The unit fake now models Prisma's lazy PrismaPromise: array-form $transaction members only execute when the transaction awaits them, which is exactly why a rejected batch leaves rows untouched. With an eager fake, "a partial failure changes nothing" is untestable. That needs one hand-made thenable, carrying a dated oxlint-disable with its reason (test-fixture scope; nothing ships). Postgres-only facts — that mode: 'insensitive' really folds case, and that the OR really reaches a row with no user id — are pinned in the integration suite.

🟡 A related gap this PR does NOT close

DataPrivacySection.tsx disables the button and shows "Nothing to delete" when the user has zero savings events, zero followed stores and zero coupon reports. Site suggestions are not in that count, so a user whose only personal data is a suggestion cannot reach this route from the UI at all — including the signed-out requester this PR was written for.

Fixing it means adding a suggestion count to the profile overview API and its query, which is a different surface (account/overview) and a different PR. Flagging rather than widening this one.

Also considered and deliberately not done

raw_url is left as typed. It is a store URL, not an identifier, and the scope named three fields. If a pasted URL could carry a personal token, nulling it is a one-line follow-up — say the word.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant