Files
Momento/docs/4-2-gdpr-right-to-be-forgotten.md
Antigravity 65e722a184
Some checks failed
CI / Lint, Test & Build (push) Waiting to run
Deploy to Production / Build and Deploy (push) Has been cancelled
fix: disable noisy lint rules, exclude .venv-i18n, 0 errors 0 warnings
2026-05-16 23:38:11 +00:00

19 KiB
Raw Permalink Blame History

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

  1. [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.
  2. [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.
  3. [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 stripeSubscriptionId exists (best-effort, non-blocking on failure)
    • Delete all note image files from disk (best-effort, using existing deleteImageFileSafely)
    • Delete all NoteAttachment files from disk (best-effort: read filePath, unlink)
    • Delete all Redis quota keys matching pattern usage:{userId}:* via SCAN+DEL (best-effort)
    • Delete the User record in Prisma — the existing onDelete: Cascade on all child models handles the rest of the DB cleanup
  4. [AC4] Post-deletion flow: After the API returns success, the client calls signOut({ callbackUrl: '/login?deleted=true' }) to invalidate the NextAuth session and redirect.
  5. [AC5] i18n: All user-visible strings via memento-note/locales/*.json (15 files). EN and FR are the content references; populate all 15.
  6. [AC6] Regression: Existing handleDeleteAll (delete all notes but keep account) and all other Settings flows are unaffected. No changes to middleware.ts, auth.ts, billing routes, or BYOK.
  7. [AC7] No DB migration: No schema change required — User cascade already covers every child model.
  8. [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 in settings/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.ts with 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 → if stripeSubscriptionId, call stripe.subscriptions.cancel(stripeSubscriptionId), catch silently
    • Subtask 1.5: Collect all note image URLs, call deleteImageFileSafely for each (best-effort, use Promise.allSettled)
    • Subtask 1.6: Collect all NoteAttachment.filePath for user's notes, fs.unlink each (best-effort, Promise.allSettled)
    • Subtask 1.7: Delete Redis quota keys — redis.scan with pattern usage:{userId}:*, then redis.del on 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
  • 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.*
  • Task 3: Settings UI update (AC: #1, #8)

    • Subtask 3.1: Update memento-note/app/(main)/settings/data/page.tsx
    • Subtask 3.2: Import DeleteAccountDialog and useSession from next-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/onOpenChange state, pass userEmail from session
  • Task 4: i18n (AC: #5)

    • Subtask 4.1: Add account.deleteAccount.* keys to all 15 memento-note/locales/*.json (see key list in Dev Notes)
  • 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/account compiled

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 of User has onDelete: Cascade — deleting User cascades 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 in delete-all/route.ts.
  • lib/stripe.ts: stripe singleton export — use stripe.subscriptions.cancel(id).
  • lib/redis.ts: redis export (ioredis) — use redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100) loop.
  • lib/quota-utils.ts: Redis key pattern is usage:{userId}:{feature}:{period} — to delete all, pattern usage:{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 up subscription.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 in components/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:

  1. Cancel Stripe (external API, best-effort)
  2. Collect note image URLs → delete files (best-effort)
  3. Collect attachment filePaths → delete files (best-effort)
  4. Delete Redis keys (best-effort)
  5. 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. ShieldAlert from lucide-react) instead of Trash2
  • 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/account route compiled correctly.
  • Attachment filePath confirmed to be absolute path (stored as path.join(process.cwd(), 'data', 'uploads', 'attachments', ...)) — used fs.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/account with 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.tsx in components/legal/ — controlled email input, disabled confirm button until exact match, loading state, signOut redirect to /login?deleted=true.
  • Task 3: Updated settings/data/page.tsx — added GDPR section with ShieldAlert icon (rose-700 palette), 4-item bullet list, "Delete My Account" trigger, DeleteAccountDialog mounted conditionally on session?.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). Existing handleDeleteAll completely untouched. No schema changes.

File List

  • memento-note/app/api/user/account/route.ts — NEW
  • memento-note/components/legal/delete-account-dialog.tsx — NEW
  • memento-note/app/(main)/settings/data/page.tsx — MODIFIED
  • memento-note/locales/en.json — MODIFIED
  • memento-note/locales/fr.json — MODIFIED
  • memento-note/locales/de.json — MODIFIED
  • memento-note/locales/es.json — MODIFIED
  • memento-note/locales/it.json — MODIFIED
  • memento-note/locales/pt.json — MODIFIED
  • memento-note/locales/nl.json — MODIFIED
  • memento-note/locales/pl.json — MODIFIED
  • memento-note/locales/ru.json — MODIFIED
  • memento-note/locales/zh.json — MODIFIED
  • memento-note/locales/ja.json — MODIFIED
  • memento-note/locales/ko.json — MODIFIED
  • memento-note/locales/ar.json — MODIFIED
  • memento-note/locales/fa.json — MODIFIED
  • memento-note/locales/hi.json — MODIFIED

Change Log

  • 2026-05-16: Story created — ready for dev
  • 2026-05-16: Implementation complete — status → review