Munchable has two endpoints that exist purely because of the UK and EU GDPR: GET /api/export-account for the right of access and data portability, and POST /api/delete-account for the right to erasure. Both are short. One of them is short for an interesting reason.
The export is short because the sensitive data is not ours
Here is the whole payload:
const payload = {
exportedAt: new Date().toISOString(),
account: { id: user.id, email: user.email ?? null },
note: 'Your conditions, sensitivities, and scan history are stored only on your device and are never sent to our servers, so they are not part of this export.',
entitlement: ent[0] ?? null,
consent: consent[0] ?? null,
contributionRewards: rewards,
productContributions: contributions,
productFeedback: feedback,
};
An account id, an email, a subscription state, a consent record, some reward rows, the product labels this person photographed, and any reports they filed about a product. That is everything the server holds about a person.
What is missing is the part the app is actually about. Munchable is a gut-health scanner: it knows which conditions you have, how sensitive you are to lactose, every product you have checked and how you felt afterwards. None of that is in the export, because none of it is on our servers. The verdict is computed on the device, and a product lookup sends a barcode and never a profile. The server is asked "what is in this product", never "what is in this product, for someone with IBS".
That architecture was chosen for its own reasons, and the export is where it pays a dividend I did not expect: the right of access is trivial to implement when your schema barely knows the person. Five queries, all keyed on one user id, no joins across a subsystem that grew its own notion of identity. If your export endpoint needs a project plan, that is telling you something about your data model rather than about GDPR.
Saying what is not in the export is part of the export
The note field is not decoration. A person who asks for their data, and receives a file with no scan history in it, has two ways to read that: either the company does not have it, or the company did not include it. Those are very different, and the file should say which.
It is also the claim the whole product rests on, so it appears in the same three places: the privacy policy, the FAQ on the landing page, and the export file itself. A claim a company only makes in marketing copy is a claim it has not been asked to honour.
Erasure has an order, and the order is the design
Deletion is three phases, and each boundary is a decision.
Phase one, Stripe, best effort:
if (subId) {
try { await stripe().subscriptions.cancel(subId); } catch { /* already canceled/gone */ }
}
if (custId) {
try { await stripe().customers.del(custId); } catch { /* already deleted/gone */ }
}
Every failure here is swallowed. Erasure must not hang on a third party being available, and the failure modes are all benign: a subscription that is already cancelled, a customer already deleted. The one thing we must not do is abort someone's erasure because an API call timed out.
Phase two, our rows, in one transaction:
await db.transaction(async (tx) => {
await tx.delete(contributionRewards).where(eq(contributionRewards.userId, uid));
await tx.delete(catalogProductFeedback).where(eq(catalogProductFeedback.userId, uid));
await tx.update(catalogProductRevisions)
.set({ contributorId: DELETED_CONTRIBUTOR })
.where(eq(catalogProductRevisions.contributorId, uid));
await tx.delete(userConsent).where(eq(userConsent.userId, uid));
await tx.delete(entitlements).where(eq(entitlements.userId, uid));
// support history below
});
Phase three, the identity itself, last:
const { error } = await supabaseAdmin().auth.admin.deleteUser(uid);
The identity goes last on purpose. Delete the auth record first and then have the transaction fail, and you have a dataset keyed to a user id that can no longer sign in, no longer request deletion, and no longer be found by anything except a manual query. Data outliving its identity is the worst state to leave a half-finished erasure in. With the identity last, a failure anywhere earlier leaves a working account that can simply press the button again.
The rows we cannot delete, and what we do instead
The genuinely hard case is contributions. When somebody scans a product Munchable does not have, they photograph its label and the reading becomes a row in the catalogue. Every later scanner of that barcode gets an answer because of that row.
Deleting it on erasure would remove a product from the catalogue for everybody else. Keeping it as-is would mean keeping a row that points at a person who asked to be forgotten.
So the personal link is severed and the fact survives:
// A fixed "deleted user" sentinel that contributed revisions are re-pointed to
// on erasure. Anonymizes the personal link (the only personal data on a
// revision) while keeping the community catalogue intact.
const DELETED_CONTRIBUTOR = '00000000-0000-0000-0000-000000000000';
The contributor id is the only personal data on a revision. The rest is the ingredients list printed on a packet, which was never personal data in the first place. The privacy policy states it in the words a reader needs:
Contributions stay in the catalogue as product data. On erasure the personal link to you is replaced with an anonymous marker, so what remains is a fact about a food rather than a fact about you.
A sentinel uuid rather than a nullable column, because contributorId is load-bearing in a dozen queries and making it nullable would push a ?? null into every one of them. One constant, one shape, and a join that finds nothing personal.
Support tickets are the most sensitive free text you hold
The other thing erasure has to take, and the one that is easy to forget:
// A ticket body is whatever the person typed, so it is the most sensitive free
// text Munchable holds and erasure has to take it. There are no FK constraints
// in this schema, so the messages are deleted by subquery rather than by cascade.
await tx.delete(supportMessages).where(
inArray(
supportMessages.ticketId,
tx.select({ id: supportTickets.id }).from(supportTickets).where(eq(supportTickets.userId, uid)),
),
);
await tx.delete(supportTickets).where(eq(supportTickets.userId, uid));
Everything else in the schema is structured: a subscription status, a consent version, a barcode. A support ticket is a free-text box, and in a health app people put things in free-text boxes that they would never type into a form field. It gets no special handling in the product and the strictest handling on erasure.
Worth checking your own schema for the equivalent: the one column where a user can type a sentence. That is your most sensitive column, whatever your data map says.
Fail closed, never half
// Without the service-role key we cannot delete the identity itself, so this
// would not be a real erasure, so fail closed rather than half-delete.
if (!hasServiceRole()) {
return NextResponse.json({ error: 'service_role_missing' }, { status: 501 });
}
If a deployment is missing the key that lets us delete the auth record, the endpoint refuses before touching anything. The alternative is deleting every row, failing at the last step, and returning an error to a person who now has an account with no data and an identity we told them was gone.
A refusal is recoverable. A half-erasure is not, and worse, the user believes it succeeded.
The two honest caveats
Neither of these is a technical achievement, but writing them down is:
Backups. They roll off on their normal cycle rather than being edited in place, so there is a window between erasure and the last backup expiring. Editing rows inside backups to satisfy a deletion request is how you get a backup that does not restore. The policy says the window exists.
Tax records. Payment records are kept for the six years HM Revenue and Customs requires. We cannot delete those on request, because keeping them is a legal obligation rather than a preference. Both are in the retention list on the privacy page, under the heading "How long we keep things", stated rather than quietly true.
What this actually costs
Two endpoints, roughly 200 lines together, and no dashboard. Nobody runs SQL by hand to satisfy a request, which means nobody can get it subtly wrong at 11pm, and the behaviour is in version control where it can be reviewed and tested rather than in a runbook.
The real cost was paid earlier and elsewhere: health data on the device means no server-side analytics on it, no "users with reflux scanned X" insight, no cross-device sync of a food log, and an account export short enough to be boring. For an app whose subject is what is wrong with your digestion, that trade was never close.
Go and look
- The privacy policy is the long form: what is on the device, what is on the server, what each retention period is, and which rights apply.
- The "How long we keep things" list on that page is the same list the deletion code implements, in reading order.
- The licences page covers the other half of the contribution question: what happens to a label photo, which is read and then discarded rather than stored.
- Both endpoints are reachable from the account screen in the app at munchable.app. The export downloads as a JSON file you can open in any editor, which is the point of portability.
Top comments (0)