Files
Momento/memento-note/app/api/user/account/route.ts
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

106 lines
3.7 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { promises as fs } from 'fs'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { redis } from '@/lib/redis'
import { stripe } from '@/lib/stripe'
import { deleteImageFileSafely, parseImageUrls } from '@/lib/image-cleanup'
/**
* DELETE /api/user/account
* GDPR Right to be Forgotten — hard deletion of the authenticated user and all associated data.
*
* Body: { email: string } (must match session.user.email for confirmation)
*
* Deletion sequence:
* 1. Cancel Stripe subscription via API (best-effort)
* 2. Delete note image files from disk (best-effort)
* 3. Delete NoteAttachment files from disk (best-effort)
* 4. Delete Redis quota keys usage:{userId}:* (best-effort)
* 5. prisma.user.delete — cascade handles all DB child records
*/
export async function DELETE(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const userEmail = session.user.email
// Verify email confirmation from request body
let body: { email?: string } = {}
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
if (!body.email || body.email !== userEmail) {
return NextResponse.json({ error: 'Email mismatch' }, { status: 400 })
}
// ── Step 1: Cancel Stripe subscription (best-effort) ──────────────────────
try {
const sub = await prisma.subscription.findUnique({ where: { userId } })
if (sub?.stripeSubscriptionId) {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId)
}
} catch {
// Non-blocking — DB record cascade-deleted below
}
// ── Step 2: Delete note image files from disk (best-effort) ───────────────
try {
const notesWithImages = await prisma.note.findMany({
where: { userId },
select: { id: true, images: true },
})
await Promise.allSettled(
notesWithImages.flatMap(note =>
parseImageUrls(note.images).map(url => deleteImageFileSafely(url, note.id))
)
)
} catch {
// Non-blocking
}
// ── Step 3: Delete NoteAttachment files from disk (best-effort) ───────────
try {
const attachments = await prisma.noteAttachment.findMany({
where: { note: { userId } },
select: { filePath: true },
})
await Promise.allSettled(
attachments.map(a => fs.unlink(a.filePath).catch(() => {}))
)
} catch {
// Non-blocking
}
// ── Step 4: Delete Redis quota keys (best-effort) ─────────────────────────
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 as [string, ...string[]]))
}
} while (cursor !== '0')
} catch {
// Non-blocking — quota keys expire naturally
}
// ── Step 5: Delete User record — cascade handles all DB child records ──────
await prisma.user.delete({ where: { id: userId } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('[DELETE /api/user/account]', error)
return NextResponse.json({ error: 'Deletion failed' }, { status: 500 })
}
}