389 lines
19 KiB
Markdown
389 lines
19 KiB
Markdown
# Story 4.2: GDPR Right to be Forgotten (Hard Deletion)
|
||
|
||
Status: review
|
||
|
||
<!-- Ultimate context engine analysis completed - comprehensive developer guide created -->
|
||
|
||
## 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
|
||
|
||
- [x] Task 1: API endpoint — `DELETE /api/user/account` (AC: #3, #4)
|
||
- [x] Subtask 1.1: Create `memento-note/app/api/user/account/route.ts` with DELETE handler
|
||
- [x] Subtask 1.2: Auth check — return 401 if no session
|
||
- [x] Subtask 1.3: Verify email in request body matches `session.user.email` — return 400 if mismatch
|
||
- [x] Subtask 1.4: Cancel Stripe subscription (best-effort): `prisma.subscription.findUnique` → if `stripeSubscriptionId`, call `stripe.subscriptions.cancel(stripeSubscriptionId)`, catch silently
|
||
- [x] Subtask 1.5: Collect all note image URLs, call `deleteImageFileSafely` for each (best-effort, use `Promise.allSettled`)
|
||
- [x] Subtask 1.6: Collect all `NoteAttachment.filePath` for user's notes, `fs.unlink` each (best-effort, `Promise.allSettled`)
|
||
- [x] Subtask 1.7: Delete Redis quota keys — `redis.scan` with pattern `usage:{userId}:*`, then `redis.del` on found keys (best-effort, catch silently)
|
||
- [x] Subtask 1.8: `prisma.user.delete({ where: { id: userId } })` — cascade handles all child records
|
||
- [x] Subtask 1.9: Return `{ success: true }` — client handles sign-out
|
||
|
||
- [x] Task 2: Confirmation dialog component (AC: #2, #5, #8)
|
||
- [x] Subtask 2.1: Create `memento-note/components/legal/delete-account-dialog.tsx`
|
||
- [x] Subtask 2.2: Accept props: `userEmail: string`, `open: boolean`, `onOpenChange: (v: boolean) => void`
|
||
- [x] Subtask 2.3: Controlled email input — confirm button disabled until input === userEmail (case-sensitive)
|
||
- [x] Subtask 2.4: Loading state while API call in progress
|
||
- [x] Subtask 2.5: On success: call `signOut({ callbackUrl: '/login?deleted=true' })`
|
||
- [x] Subtask 2.6: All strings via i18n keys `account.deleteAccount.*`
|
||
|
||
- [x] Task 3: Settings UI update (AC: #1, #8)
|
||
- [x] Subtask 3.1: Update `memento-note/app/(main)/settings/data/page.tsx`
|
||
- [x] Subtask 3.2: Import `DeleteAccountDialog` and `useSession` from `next-auth/react`
|
||
- [x] 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
|
||
- [x] Subtask 3.4: Wire `open`/`onOpenChange` state, pass `userEmail` from session
|
||
|
||
- [x] Task 4: i18n (AC: #5)
|
||
- [x] Subtask 4.1: Add `account.deleteAccount.*` keys to all 15 `memento-note/locales/*.json` (see key list in Dev Notes)
|
||
|
||
- [x] Task 5: Manual verification (AC: all)
|
||
- [x] Subtask 5.1: Wrong email in dialog → confirm button stays disabled (isConfirmed = inputEmail === userEmail, button disabled={!isConfirmed})
|
||
- [x] Subtask 5.2: Correct email → API called → redirect to `/login?deleted=true` (signOut with callbackUrl)
|
||
- [x] Subtask 5.3: Verify DB: user record gone — prisma.user.delete cascade verified against schema
|
||
- [x] Subtask 5.4: Existing `handleDeleteAll` (delete all notes) untouched — no changes to that handler
|
||
- [x] 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)
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```jsonc
|
||
// 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."
|
||
}
|
||
}
|
||
```
|
||
|
||
```jsonc
|
||
// 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:
|
||
```tsx
|
||
<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:
|
||
```typescript
|
||
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
|