106 lines
3.7 KiB
TypeScript
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 })
|
|
}
|
|
}
|