Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions apps/caramel-app/src/app/api/account/data/delete/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { withRoute } from '@/lib/api/withRoute'
import prisma from '@/lib/prisma'
import type { Prisma } from '@prisma/client'
import { NextResponse } from 'next/server'
import { z } from 'zod'

// POST /api/account/data/delete — the danger zone's "Delete my data".
//
// Removes the three login-features tables' rows for the caller: their synced
// savings history, the stores they follow, and the coupon reports they made.
// It also SCRUBS the caller's identity off their site suggestions — see below.
//
// WHAT IT DELIBERATELY DOES NOT DO:
// - It does NOT delete the account or the login. That is a larger job (Better
// Auth session teardown, extension session revocation, an email
// confirmation step) and is out of scope here. See the TODO in
// DataPrivacySection.tsx.
// - It does NOT delete site_suggestions rows, it SCRUBS them. The row's
// identifying half (user_id, requester_email, user_agent) is nulled; the
// domain and the status stay. A "please support this store" request is not
// personal data once the requester is off it, and it is the pipeline's
// input: deleting the row would silently retract a store request that other
// people may also have made, and would corrupt a queue this user does not
// own. Nothing new is stamped — a scrub is not an ANSWER to the request.
// - It does NOT flip the savings-sync preference. Turning sync OFF and
// DELETING history are two deliberately separate acts: a delete that also
// changed a setting the user never touched would make the danger zone do
Expand Down Expand Up @@ -46,18 +55,59 @@ export const POST = withRoute(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const email = session.user.email ?? null

// A site suggestion is matched TWO ways, and the second is the one that
// matters: `user_id` finds the requests made while signed in, and 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 exactly the email this route exists to remove.
//
// 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 would walk straight past the row.
// An account with no email on it (the schema allows one) contributes no
// email branch rather than a `null` one, which would match every
// anonymous suggestion ever made.
const suggestionIdentity: Prisma.SiteSuggestionWhereInput[] = [
{ userId },
...(email
? [
{
requesterEmail: {
equals: email,
mode: 'insensitive' as const,
},
},
]
: []),
]

// ONE transaction. A partial delete is the worst outcome available
// here: the user is told their data is gone while some of it remains,
// and the counts the UI just showed them become a lie. If any of the
// three fails, Prisma rolls the whole interactive batch back and
// four fails, Prisma rolls the whole interactive batch back and
// withRoute's catch turns it into a 500 + Sentry — nothing deleted,
// and the failure toast ("Nothing was removed") is then true.
const [savingsEvents, favoriteStores, couponReports] =
//
// The scrub belongs IN here for exactly that reason: run as a fourth
// loose await after a successful transaction, a failure would leave the
// three tables emptied and the email still sitting in site_suggestions,
// with a 500 telling the user nothing had been removed.
const [savingsEvents, favoriteStores, couponReports, siteSuggestions] =
await prisma.$transaction([
prisma.savingsEvent.deleteMany({ where: { userId } }),
prisma.favoriteStore.deleteMany({ where: { userId } }),
prisma.couponReport.deleteMany({ where: { userId } }),
prisma.siteSuggestion.updateMany({
where: { OR: suggestionIdentity },
data: {
userId: null,
requesterEmail: null,
userAgent: null,
},
}),
])

return NextResponse.json({
Expand All @@ -66,6 +116,10 @@ export const POST = withRoute(
favoriteStores: favoriteStores.count,
couponReports: couponReports.count,
},
// Reported under its own key, never folded into `deleted`: these
// rows still exist, and calling that a deletion would be a lie the
// next reader of this response would believe.
scrubbed: { siteSuggestions: siteSuggestions.count },
})
},
)
106 changes: 106 additions & 0 deletions apps/caramel-app/tests/integration/site-suggestions.itest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { POST as deleteMyData } from '@/app/api/account/data/delete/route'
import { POST } from '@/app/api/sites/suggest/route'
import prisma from '@/lib/prisma'
import {
Expand Down Expand Up @@ -392,3 +393,108 @@ describe('site suggestions — answering a request (real Postgres)', () => {
).resolves.toEqual({ notifiedAt: toldAt })
})
})

// "Delete my data" and the requester identity (#227), on real Postgres.
//
// The unit suite imitates `mode: 'insensitive'` in its in-memory fake. Whether
// Postgres actually folds the case — and whether the OR really reaches a row
// that carries no user id at all — is a property of the database, so it is
// settled here.
describe('delete-my-data scrubs the requester identity (real Postgres)', () => {
it('scrubs the signed-in row AND the email-only row typed in a different case, and leaves a third user alone', async () => {
const mine = await createRow(`mine.${ITEST_DOMAIN_SUFFIX}`)
await prisma.siteSuggestion.update({
where: { id: mine },
data: {
userId,
requesterEmail: USER_EMAIL,
userAgent: 'their laptop',
},
})
// Made while SIGNED OUT: no user id, and the address as THEY typed it.
const anonymous = await createRow(`anon.${ITEST_DOMAIN_SUFFIX}`)
await prisma.siteSuggestion.update({
where: { id: anonymous },
data: {
requesterEmail: USER_EMAIL.toUpperCase(),
userAgent: 'their phone',
},
})
const stranger = await createRow(`stranger.${ITEST_DOMAIN_SUFFIX}`)
await prisma.siteSuggestion.update({
where: { id: stranger },
data: {
requesterEmail: `someone-else@${ITEST_DOMAIN_SUFFIX}`,
userAgent: 'not theirs',
},
})

getSessionMock.mockResolvedValue({
user: { id: userId, email: USER_EMAIL },
})
const res = await deleteMyData(
new NextRequest('http://localhost/api/account/data/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ confirm: 'DELETE' }),
}),
)
expect(res.status).toBe(200)
expect((await res.json()).scrubbed).toEqual({ siteSuggestions: 2 })

for (const id of [mine, anonymous]) {
await expect(
prisma.siteSuggestion.findUniqueOrThrow({
where: { id },
select: {
userId: true,
requesterEmail: true,
userAgent: true,
},
}),
).resolves.toEqual({
userId: null,
requesterEmail: null,
userAgent: null,
})
}
await expect(
prisma.siteSuggestion.findUniqueOrThrow({
where: { id: stranger },
select: { requesterEmail: true, userAgent: true },
}),
).resolves.toEqual({
requesterEmail: `someone-else@${ITEST_DOMAIN_SUFFIX}`,
userAgent: 'not theirs',
})
})

it('the store request itself survives the scrub, and stays drainable by the pipeline', async () => {
const id = await createRow(`survives.${ITEST_DOMAIN_SUFFIX}`)
await prisma.siteSuggestion.update({
where: { id },
data: { userId, requesterEmail: USER_EMAIL },
})
getSessionMock.mockResolvedValue({
user: { id: userId, email: USER_EMAIL },
})
await deleteMyData(
new NextRequest('http://localhost/api/account/data/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ confirm: 'DELETE' }),
}),
)

// Still a `new` row the pipeline will hand out, with its domain intact.
const listed = (
await listSiteSuggestions({ status: 'new', limit: 500 })
).filter(s => s.id === id)
expect(listed).toHaveLength(1)
expect(listed[0]).toMatchObject({
domain: `survives.${ITEST_DOMAIN_SUFFIX}`,
requesterEmail: null,
status: 'new',
})
})
})
Loading
Loading