From e691124058e31b3c8144205ee0c5be5ec0526c87 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 28 Apr 2026 10:59:37 +0500 Subject: [PATCH] Fix LessonFile duplication: upsert on upload, delete S3 on remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/admin/lesson-files now checks for an existing record with the same (lessonId, name) before uploading — replaces it (old S3 object deleted) instead of always creating a new one. Previously every save cycle accumulated an extra copy; 1183 duplicates occupying 6.5 GiB were found and cleaned up. DELETE now receives the file URL and extracts the S3 key from it, so manual deletion actually removes the object from storage. Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/admin/lesson-files/route.ts | 33 ++++++++++++++----- src/components/admin/lesson-files-manager.tsx | 6 ++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/app/api/admin/lesson-files/route.ts b/src/app/api/admin/lesson-files/route.ts index f4469ef..c515f98 100644 --- a/src/app/api/admin/lesson-files/route.ts +++ b/src/app/api/admin/lesson-files/route.ts @@ -5,14 +5,14 @@ import { prisma } from "@/lib/prisma"; import { uploadFile, deleteFile } from "@/lib/s3"; import { randomUUID } from "crypto"; -async function requireAdmin(req: NextRequest) { +async function requireAdmin() { const session = await auth.api.getSession({ headers: await headers() }); if (!session || session.user.role !== "admin") return null; return session; } export async function POST(req: NextRequest) { - if (!await requireAdmin(req)) { + if (!await requireAdmin()) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } @@ -22,20 +22,32 @@ export async function POST(req: NextRequest) { const label = (form.get("label") as string | null)?.trim() || null; if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + const name = label ?? file.name; + const existing = await prisma.lessonFile.findFirst({ where: { lessonId, name } }); + const ext = file.name.split(".").pop() ?? "bin"; const key = `lessons/${lessonId}/${randomUUID()}.${ext}`; const buffer = Buffer.from(await file.arrayBuffer()); const url = await uploadFile(key, buffer, file.type); - const lessonFile = await prisma.lessonFile.create({ - data: { lessonId, name: label ?? file.name, url, size: file.size }, - }); + if (existing) { + const oldKey = existing.url.split(`/${process.env.S3_BUCKET}/`)[1]; + if (oldKey) await deleteFile(oldKey).catch(() => {}); + const lessonFile = await prisma.lessonFile.update({ + where: { id: existing.id }, + data: { url, size: file.size }, + }); + return NextResponse.json(lessonFile); + } + const lessonFile = await prisma.lessonFile.create({ + data: { lessonId, name, url, size: file.size }, + }); return NextResponse.json(lessonFile); } export async function PATCH(req: NextRequest) { - if (!await requireAdmin(req)) { + if (!await requireAdmin()) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } @@ -51,12 +63,15 @@ export async function PATCH(req: NextRequest) { } export async function DELETE(req: NextRequest) { - if (!await requireAdmin(req)) { + if (!await requireAdmin()) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const { fileId, key } = await req.json(); - if (key) await deleteFile(key).catch(() => {}); + const { fileId, url } = await req.json(); + if (url) { + const key = (url as string).split(`/${process.env.S3_BUCKET}/`)[1]; + if (key) await deleteFile(key).catch(() => {}); + } await prisma.lessonFile.delete({ where: { id: fileId } }); return NextResponse.json({ ok: true }); } diff --git a/src/components/admin/lesson-files-manager.tsx b/src/components/admin/lesson-files-manager.tsx index c4685d9..8d6f5f8 100644 --- a/src/components/admin/lesson-files-manager.tsx +++ b/src/components/admin/lesson-files-manager.tsx @@ -36,12 +36,12 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin e.target.value = ""; } - async function handleDelete(fileId: string) { + async function handleDelete(fileId: string, url: string) { if (!confirm("Удалить файл?")) return; await fetch("/api/admin/lesson-files", { method: "DELETE", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileId }), + body: JSON.stringify({ fileId, url }), }); setFiles((prev) => prev.filter((f) => f.id !== fileId)); } @@ -122,7 +122,7 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin