DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our delete all my data button leaves one table alone, and the dialog says so

Two of the controls a privacy policy promises, export everything and delete everything, live in the same route file in our app. Neither is complicated. The decisions inside them are, and the one I want to write down is an exclusion.

Export: seven tables, one round trip, one header

The export mode fires every read at once and assembles a single JSON document:

const [userRows, sessions, interviews, creditsRows, transactions, onboardingRows] =
  await Promise.all([ /* ... */ ]);
Enter fullscreen mode Exit fullscreen mode

Then a header turns it into a download rather than a page:

return NextResponse.json(exportData, {
  headers: {
    'Content-Disposition': `attachment; filename="user-data-export-${userId}-${Date.now()}.json"`,
  },
});
Enter fullscreen mode Exit fullscreen mode

The inclusion worth noting is the onboarding answers, which are what somebody told us they are applying for. The comment explains why they are in there:

Included so a data export is genuinely everything we hold: the onboarding answers are personal data even though the user gave them as a preference.

That distinction catches people out. Data a user volunteered as a setting feels like configuration rather than personal data, and it is still personal data. The export also surfaces the field that says when those answers delete themselves, as automaticDeletionAt, because a retention promise the user cannot see is a retention promise they have to take on trust.

The same route serves two lighter modes selected by query parameter, a preview and a paginated feedback history, and the audit log records which one you asked for: data_export for the download, data_access for a read. One route, three answers, three different rows in your own activity log.

Delete: a transaction, and a comment about pipelining

The bulk delete counts first, so the response can tell the user what went, then issues four statements:

const [, , onboardingDeleted, reviewPromptDeleted] = await Promise.all([
  tx.delete(gameSessionsTable).where(eq(gameSessionsTable.user_id, userId)),
  tx.delete(interviewsTable).where(eq(interviewsTable.user_id, userId)),
  tx.delete(onboardingProfilesTable).where(...).returning({ id: ... }),
  tx.delete(satisfactionSurveysTable).where(...).returning({ id: ... }),
]);
Enter fullscreen mode Exit fullscreen mode

Promise.all inside a transaction looks like a concurrency bug, so the comment above it says what it actually is:

This is pipelining, not parallelism: the transaction holds a single reserved connection and Postgres still runs the four statements in order, so what it buys is one network round trip instead of four, with no concurrency to reason about.

Worth knowing if you have ever flinched at that pattern in a code review. The connection is reserved, the ordering is unchanged, and the only thing saved is latency.

The exclusion

Exercise attempts are deliberately not deleted by this control, and that is the one decision in the file that needed a paragraph rather than a line.

The reason is metering. Our per-exercise AI scoring budget is counted off exactly those attempt rows. If a button in Settings cleared them, every customer would have an unlimited reset for a paid feature one click away. So the privacy policy retains attempts until the account itself is deleted, and states the reason in the policy rather than only in the code, and the confirmation dialog lists what goes, with exercises not on the list.

That ordering matters more than the code does. A deletion control that quietly keeps something is a broken promise. A deletion control that keeps something the policy named, for a reason the policy gave, with a dialog that enumerates what it will actually remove, is a design. The difference is entirely in whether the user was told.

The thing we delete even though it costs us

The opposite trade appears two lines up. The marker that records we once asked for a review goes with the sessions, and removing it returns the account to "never asked", which means the review prompt can appear again on the very next render.

We delete it anyway. It is the only record that we asked, so keeping it back would be keeping data through a control whose entire promise is that it does not keep data. Suppressing a repeat ask is the job of the thing that decides to render the prompt, not of a deletion endpoint, and a deletion endpoint that starts making product decisions is how exclusions multiply.

See it

Open cogniprep.app/privacy and find the Data Retention section. One line reads:

Assessment centre exercise attempts (including your written answers and any AI feedback): retained until you delete your account, so we can enforce per-exercise usage limits; removed immediately when you delete your account

That sentence is the exclusion above, written for a reader rather than a reviewer, including the reason. Compare it with the line two bullets up about game sessions, which are removed immediately when you delete your account or request removal. Two different promises about two tables in the same endpoint, and the code is only defensible because the page makes the difference explicit.

If you have an account, both controls are in Settings, and the export is a plain JSON file you can read. If you are building the same thing, the test I would apply is this one: list every table your delete-everything button skips, then check whether your privacy policy names each of them. Ours did not, once, and fixing the sentence was the real work.

Top comments (0)