Fix LessonFile duplication: upsert on upload, delete S3 on remove

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 10:59:37 +05:00
parent fdb9f96382
commit e691124058
2 changed files with 27 additions and 12 deletions
+24 -9
View File
@@ -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 });
}
@@ -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
</span>
<button
type="button"
onClick={() => handleDelete(f.id)}
onClick={() => handleDelete(f.id, f.url)}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>