19 KiB
Story 4.2: GDPR Right to be Forgotten (Hard Deletion)
Status: review
Story
As a user, I want the ability to perform a complete, hard deletion of my account, so that I can exercise my Right to be Forgotten under GDPR.
Epic: Epic 4 — Enterprise Compliance & Privacy (B2B Requirements)
FR coverage: NFR-GDPR2 (hard deletion of all vector data, BYOK keys, and user records)
Out of scope for this story: Story 4.3 (data export), Story 4.4 (AI consent modal), Story 4.5 (EU data residency)
Acceptance Criteria
- [AC1] GDPR section in Settings → Data: A clearly labelled "GDPR — Right to be Forgotten" section appears below the existing Danger Zone in
settings/data. It lists explicitly what will be deleted: notes, notebooks, pgvector embeddings, BYOK API keys, AI history, Brainstorm sessions, subscription data, and the account itself. - [AC2] Email confirmation dialog: Clicking "Delete My Account" opens a modal dialog that requires the user to type their exact email address before the destructive action is enabled. The confirm button stays disabled until the input matches.
- [AC3] Hard deletion sequence (server-side): On confirmed deletion the API endpoint (
DELETE /api/user/account) executes in this order:- Cancel Stripe subscription via Stripe API if
stripeSubscriptionIdexists (best-effort, non-blocking on failure) - Delete all note image files from disk (best-effort, using existing
deleteImageFileSafely) - Delete all
NoteAttachmentfiles from disk (best-effort: readfilePath, unlink) - Delete all Redis quota keys matching pattern
usage:{userId}:*via SCAN+DEL (best-effort) - Delete the
Userrecord in Prisma — the existingonDelete: Cascadeon all child models handles the rest of the DB cleanup
- Cancel Stripe subscription via Stripe API if
- [AC4] Post-deletion flow: After the API returns success, the client calls
signOut({ callbackUrl: '/login?deleted=true' })to invalidate the NextAuth session and redirect. - [AC5] i18n: All user-visible strings via
memento-note/locales/*.json(15 files). EN and FR are the content references; populate all 15. - [AC6] Regression: Existing
handleDeleteAll(delete all notes but keep account) and all other Settings flows are unaffected. No changes tomiddleware.ts,auth.ts, billing routes, or BYOK. - [AC7] No DB migration: No schema change required — User cascade already covers every child model.
- [AC8] Design consistency: Dialog and new section use existing design tokens (
--ink,--concrete,--border,--memento-paper, rose palette for danger zone) — no new blue/legacy theme colors; matches existing Danger Zone style insettings/data.
Tasks / Subtasks
-
Task 1: API endpoint —
DELETE /api/user/account(AC: #3, #4)- Subtask 1.1: Create
memento-note/app/api/user/account/route.tswith DELETE handler - Subtask 1.2: Auth check — return 401 if no session
- Subtask 1.3: Verify email in request body matches
session.user.email— return 400 if mismatch - Subtask 1.4: Cancel Stripe subscription (best-effort):
prisma.subscription.findUnique→ ifstripeSubscriptionId, callstripe.subscriptions.cancel(stripeSubscriptionId), catch silently - Subtask 1.5: Collect all note image URLs, call
deleteImageFileSafelyfor each (best-effort, usePromise.allSettled) - Subtask 1.6: Collect all
NoteAttachment.filePathfor user's notes,fs.unlinkeach (best-effort,Promise.allSettled) - Subtask 1.7: Delete Redis quota keys —
redis.scanwith patternusage:{userId}:*, thenredis.delon found keys (best-effort, catch silently) - Subtask 1.8:
prisma.user.delete({ where: { id: userId } })— cascade handles all child records - Subtask 1.9: Return
{ success: true }— client handles sign-out
- Subtask 1.1: Create
-
Task 2: Confirmation dialog component (AC: #2, #5, #8)
- Subtask 2.1: Create
memento-note/components/legal/delete-account-dialog.tsx - Subtask 2.2: Accept props:
userEmail: string,open: boolean,onOpenChange: (v: boolean) => void - Subtask 2.3: Controlled email input — confirm button disabled until input === userEmail (case-sensitive)
- Subtask 2.4: Loading state while API call in progress
- Subtask 2.5: On success: call
signOut({ callbackUrl: '/login?deleted=true' }) - Subtask 2.6: All strings via i18n keys
account.deleteAccount.*
- Subtask 2.1: Create
-
Task 3: Settings UI update (AC: #1, #8)
- Subtask 3.1: Update
memento-note/app/(main)/settings/data/page.tsx - Subtask 3.2: Import
DeleteAccountDialoganduseSessionfromnext-auth/react - Subtask 3.3: Add GDPR section after existing Danger Zone
<div>: rose/border styled card with explicit list of what gets deleted and the "Delete Account" trigger button - Subtask 3.4: Wire
open/onOpenChangestate, passuserEmailfrom session
- Subtask 3.1: Update
-
Task 4: i18n (AC: #5)
- Subtask 4.1: Add
account.deleteAccount.*keys to all 15memento-note/locales/*.json(see key list in Dev Notes)
- Subtask 4.1: Add
-
Task 5: Manual verification (AC: all)
- Subtask 5.1: Wrong email in dialog → confirm button stays disabled (isConfirmed = inputEmail === userEmail, button disabled={!isConfirmed})
- Subtask 5.2: Correct email → API called → redirect to
/login?deleted=true(signOut with callbackUrl) - Subtask 5.3: Verify DB: user record gone — prisma.user.delete cascade verified against schema
- Subtask 5.4: Existing
handleDeleteAll(delete all notes) untouched — no changes to that handler - Subtask 5.5:
npm run build— ✅ 0 errors,/api/user/accountcompiled
Dev Notes
Epic context (Epic 4)
| Story | Scope | Dependency on 4.2 |
|---|---|---|
| 4.1 | Cookie banner + category consent | Done ✓ |
| 4.2 | Hard account deletion | — (this story) |
| 4.3 | Data export portability | Independent |
| 4.4 | AI processing consent (just-in-time modal) | Independent |
| 4.5 | EU data residency | Independent |
| 4.6 | SSO/SAML + audit logs | Independent |
Critical brownfield reality
Already in codebase:
prisma/schema.prisma: Every child model ofUserhasonDelete: Cascade— deletingUsercascades to:Account,Session,Notebook(→ notes, labels),Note(→NoteEmbedding,NoteHistory,NoteShare,AiFeedback),UserAISettings,Agent,Conversation,Canvas,Workflow,Notification,BrainstormSession(→ ideas, participants, activities, shares, snapshots),UserAPIKey,Subscription,UsageLog,MemoryEchoInsight,BrainstormParticipant,BrainstormShare,BrainstormActivity.- NoteEmbedding (pgvector data) is deleted automatically via Note cascade. No explicit step needed in code beyond confirming cascade is wired.
- UserAPIKey (BYOK keys — encrypted AES-256-GCM) deleted automatically via User cascade.
lib/image-cleanup.ts:deleteImageFileSafely(imageUrl, excludeNoteId?)+parseImageUrls(imagesJson)— use exactly as indelete-all/route.ts.lib/stripe.ts:stripesingleton export — usestripe.subscriptions.cancel(id).lib/redis.ts:redisexport (ioredis) — useredis.scan(cursor, 'MATCH', pattern, 'COUNT', 100)loop.lib/quota-utils.ts: Redis key pattern isusage:{userId}:{feature}:{period}— to delete all, patternusage:{userId}:*via SCAN.app/api/notes/delete-all/route.ts: Reference implementation for image file cleanup before notes deletion — copy the pattern.app/api/billing/portal/route.ts: Shows how to look upsubscription.stripeSubscriptionId.settings/data/page.tsx: Existing "Danger Zone" with rose palette — match exact styles for the new GDPR section.components/legal/cookie-consent-banner.tsx/cookie-preferences-dialog.tsx: Existing components incomponents/legal/to understand component structure patterns in this directory.
Not implemented (this story):
- No account deletion API or UI.
- No confirmation dialog for account deletion.
- No Redis quota key cleanup on user deletion.
- No NoteAttachment file cleanup on user deletion (only note image cleanup exists in
delete-all).
NoteAttachment file cleanup (IMPORTANT — not in existing delete-all)
// Step: fetch attachment filePaths BEFORE User delete
const attachments = await prisma.noteAttachment.findMany({
where: { note: { userId } },
select: { filePath: true },
})
// Then after collecting, delete files best-effort
await Promise.allSettled(
attachments.map(a =>
fs.unlink(path.join(process.cwd(), a.filePath)).catch(() => {})
)
)
The NoteAttachment.filePath is stored as a relative path (e.g. data/uploads/attachments/...). Check app/api/upload/route.ts for the exact path format if needed.
Redis SCAN pattern (best-effort, non-blocking)
import { redis } from '@/lib/redis'
async function deleteUserRedisKeys(userId: string): Promise<void> {
try {
const pattern = `usage:${userId}:*`
let cursor = '0'
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', '100')
cursor = nextCursor
if (keys.length > 0) {
await redis.del(...keys)
}
} while (cursor !== '0')
} catch {
// Non-blocking — quota keys expire naturally; GDPR only requires DB+file cleanup
}
}
Stripe cancellation pattern (best-effort)
import { stripe } from '@/lib/stripe'
// In the DELETE handler, before prisma.user.delete:
try {
const sub = await prisma.subscription.findUnique({ where: { userId } })
if (sub?.stripeSubscriptionId) {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId)
}
} catch {
// Non-blocking — DB record will be cascade-deleted anyway
}
Correct deletion order (IMPORTANT)
Execute in this order to avoid FK constraint errors:
- Cancel Stripe (external API, best-effort)
- Collect note image URLs → delete files (best-effort)
- Collect attachment filePaths → delete files (best-effort)
- Delete Redis keys (best-effort)
prisma.user.delete({ where: { id: userId } })— single call, cascade handles all DB records
Do NOT call deleteMany on child tables first — the cascade will handle everything correctly.
Confirmation dialog — email validation logic
// In delete-account-dialog.tsx
const [inputEmail, setInputEmail] = useState('')
const isConfirmed = inputEmail === userEmail // case-sensitive, exact match
Use next-auth/react useSession() in the parent (data/page.tsx) to get session.user.email, pass as prop. The dialog itself does not need useSession.
API route signature
DELETE /api/user/account
Body: { email: string } // user must confirm their own email
Auth: NextAuth session required
Response 200: { success: true }
Response 400: { error: 'Email mismatch' }
Response 401: { error: 'Unauthorized' }
Response 500: { error: 'Deletion failed' }
i18n keys to add (EN and FR as content reference)
// EN content:
"account": {
"deleteAccount": {
"sectionTitle": "Right to be Forgotten (GDPR)",
"sectionDescription": "Permanently and irreversibly delete your account and all associated data.",
"whatWillBeDeleted": "The following will be permanently deleted:",
"items": [
"All notes, notebooks, and attachments",
"All pgvector semantic embeddings",
"All BYOK API keys",
"All AI conversations and brainstorm sessions",
"Quota and usage history",
"Your Stripe subscription (if active)",
"Your account and login credentials"
],
"buttonLabel": "Delete My Account",
"dialogTitle": "Confirm Account Deletion",
"dialogDescription": "This action is irreversible. Type your email address to confirm.",
"emailPlaceholder": "Your email address",
"confirmButton": "Permanently Delete Account",
"cancelButton": "Cancel",
"deleting": "Deleting...",
"successRedirect": "Your account has been deleted.",
"errorFailed": "Deletion failed. Please try again."
}
}
// FR content:
"account": {
"deleteAccount": {
"sectionTitle": "Droit à l'Oubli (RGPD)",
"sectionDescription": "Supprimez définitivement et irréversiblement votre compte et toutes vos données.",
"whatWillBeDeleted": "Les éléments suivants seront supprimés définitivement :",
"items": [
"Toutes vos notes, carnets et pièces jointes",
"Tous vos embeddings sémantiques pgvector",
"Toutes vos clés BYOK",
"Tous vos historiques IA et sessions Brainstorm",
"Historique des quotas et usages",
"Votre abonnement Stripe (si actif)",
"Votre compte et vos identifiants"
],
"buttonLabel": "Supprimer mon compte",
"dialogTitle": "Confirmer la suppression du compte",
"dialogDescription": "Cette action est irréversible. Saisissez votre adresse e-mail pour confirmer.",
"emailPlaceholder": "Votre adresse e-mail",
"confirmButton": "Supprimer définitivement le compte",
"cancelButton": "Annuler",
"deleting": "Suppression...",
"successRedirect": "Votre compte a été supprimé.",
"errorFailed": "La suppression a échoué. Veuillez réessayer."
}
}
Note: items is an array in JSON — use indexed keys if t() doesn't support arrays, e.g. account.deleteAccount.item1, account.deleteAccount.item2, etc. Prefer a static list rendered in the component rather than dynamic i18n array.
Files to create
memento-note/
├── app/api/user/account/route.ts # NEW — DELETE /api/user/account
├── components/legal/delete-account-dialog.tsx # NEW — confirmation dialog
Files to update
| File | Change |
|---|---|
app/(main)/settings/data/page.tsx |
Add GDPR section + wire DeleteAccountDialog |
memento-note/locales/*.json (×15) |
Add account.deleteAccount.* keys |
Do not modify unless required: middleware.ts, auth.ts, prisma/schema.prisma, Stripe/billing routes, BYOK routes, cookie consent components (4.1 work).
Previous story intelligence (4.1)
| Pattern | Relevance |
|---|---|
components/legal/ directory |
Already established — new dialog goes here |
i18n via useLanguage() / t() |
Same pattern to follow |
Design tokens (--ink, --concrete, rose palette) |
Already used in data/page.tsx Danger Zone — exact match |
| No DB migration policy | Confirmed: no schema change needed |
| No automated tests | Project policy — manual checklist only |
From 4.1 completion notes: The cookie preferences dialog uses Dialog from Radix (via shadcn/ui). Use the same pattern for DeleteAccountDialog to ensure consistent modal behavior.
Design guidance — new GDPR section
The existing Danger Zone section in settings/data/page.tsx uses:
<div className="bg-rose-50/50 dark:bg-rose-500/5 rounded-2xl border border-rose-200/50 dark:border-rose-500/20 p-8 mt-12">
The new GDPR section should be visually distinct from but consistent with the existing Danger Zone:
- Same rose palette but optionally a slightly deeper shade (e.g.
bg-rose-100/50) to signal higher severity - Add a GDPR/shield icon (e.g.
ShieldAlertfrom lucide-react) instead ofTrash2 - Use a bullet list to enumerate what will be deleted (renders from component, not i18n array)
- Button: same style as existing red button
bg-rose-600 text-white
Do not collapse into the existing Danger Zone <div> — keep as a separate section with its own heading.
Git intelligence (recent)
Story 4.1 (cookie consent) was the most recent Epic 4 work. The relevant files modified:
app/layout.tsx(added<CookieConsentRoot />)lib/consent/cookie-consent.ts(new)lib/analytics/track.ts(new)components/legal/(new directory)locales/*.json(15 files modified)
Safe to proceed — no conflicts expected. The components/legal/ directory and i18n patterns are established.
Stripe API version
Current lib/stripe.ts uses apiVersion: '2026-04-22.dahlia'. The cancel subscription call is stable:
await stripe.subscriptions.cancel(stripeSubscriptionId)
// No `{ prorate: false }` needed — user is deleting account, not just cancelling
Testing policy
Per project policy: no automated tests unless explicitly requested. Use Task 5 manual checklist only.
References
- [Source: docs/epics.md — Epic 4, Story 4.2, NFR-GDPR2]
- [Source: docs/ux-design-specification.md — Emplacement Légal, "Suppression Définitive" dans les paramètres du compte]
- [Source: memento-note/prisma/schema.prisma — User model + all cascade relations]
- [Source: memento-note/app/api/notes/delete-all/route.ts — image cleanup pattern]
- [Source: memento-note/lib/quota-utils.ts — Redis key format
usage:{userId}:{feature}:{period}] - [Source: memento-note/lib/stripe.ts — stripe singleton]
- [Source: memento-note/app/(main)/settings/data/page.tsx — existing Danger Zone UI pattern]
- [Source: memento-note/lib/image-cleanup.ts — deleteImageFileSafely]
Dev Agent Record
Agent Model Used
Claude Sonnet 4.6 (Cursor)
Debug Log References
npm run build→ exit 0, no TypeScript errors,/api/user/accountroute compiled correctly.- Attachment
filePathconfirmed to be absolute path (stored aspath.join(process.cwd(), 'data', 'uploads', 'attachments', ...)) — usedfs.unlink(a.filePath)directly. - Redis
redis.del(...keys)requires spread with typed array[string, ...string[]]cast to satisfy ioredis types.
Completion Notes List
- ✅ Task 1: Created
DELETE /api/user/accountwith full hard-deletion sequence: Stripe cancel → image files → attachment files → Redis keys → Prisma user.delete (cascade). All external steps are best-effort (caught silently). - ✅ Task 2: Created
delete-account-dialog.tsxincomponents/legal/— controlled email input, disabled confirm button until exact match, loading state,signOutredirect to/login?deleted=true. - ✅ Task 3: Updated
settings/data/page.tsx— added GDPR section withShieldAlerticon (rose-700 palette), 4-item bullet list, "Delete My Account" trigger,DeleteAccountDialogmounted conditionally onsession?.user?.email. - ✅ Task 4: Added
account.deleteAccount.*keys (17 keys) to all 15 locale files (en, fr, de, es, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) via Python script. - ✅ Task 5: Build verification passed. Logic verified: confirm button disabled until
inputEmail === userEmail(case-sensitive). ExistinghandleDeleteAllcompletely untouched. No schema changes.
File List
memento-note/app/api/user/account/route.ts— NEWmemento-note/components/legal/delete-account-dialog.tsx— NEWmemento-note/app/(main)/settings/data/page.tsx— MODIFIEDmemento-note/locales/en.json— MODIFIEDmemento-note/locales/fr.json— MODIFIEDmemento-note/locales/de.json— MODIFIEDmemento-note/locales/es.json— MODIFIEDmemento-note/locales/it.json— MODIFIEDmemento-note/locales/pt.json— MODIFIEDmemento-note/locales/nl.json— MODIFIEDmemento-note/locales/pl.json— MODIFIEDmemento-note/locales/ru.json— MODIFIEDmemento-note/locales/zh.json— MODIFIEDmemento-note/locales/ja.json— MODIFIEDmemento-note/locales/ko.json— MODIFIEDmemento-note/locales/ar.json— MODIFIEDmemento-note/locales/fa.json— MODIFIEDmemento-note/locales/hi.json— MODIFIED
Change Log
- 2026-05-16: Story created — ready for dev
- 2026-05-16: Implementation complete — status → review