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:
@@ -5,14 +5,14 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { uploadFile, deleteFile } from "@/lib/s3";
|
import { uploadFile, deleteFile } from "@/lib/s3";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
async function requireAdmin(req: NextRequest) {
|
async function requireAdmin() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session || session.user.role !== "admin") return null;
|
if (!session || session.user.role !== "admin") return null;
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
if (!await requireAdmin(req)) {
|
if (!await requireAdmin()) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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;
|
const label = (form.get("label") as string | null)?.trim() || null;
|
||||||
if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
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 ext = file.name.split(".").pop() ?? "bin";
|
||||||
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
|
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const url = await uploadFile(key, buffer, file.type);
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|
||||||
const lessonFile = await prisma.lessonFile.create({
|
if (existing) {
|
||||||
data: { lessonId, name: label ?? file.name, url, size: file.size },
|
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);
|
return NextResponse.json(lessonFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
if (!await requireAdmin(req)) {
|
if (!await requireAdmin()) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +63,15 @@ export async function PATCH(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
if (!await requireAdmin(req)) {
|
if (!await requireAdmin()) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fileId, key } = await req.json();
|
const { fileId, url } = await req.json();
|
||||||
if (key) await deleteFile(key).catch(() => {});
|
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 } });
|
await prisma.lessonFile.delete({ where: { id: fileId } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(fileId: string) {
|
async function handleDelete(fileId: string, url: string) {
|
||||||
if (!confirm("Удалить файл?")) return;
|
if (!confirm("Удалить файл?")) return;
|
||||||
await fetch("/api/admin/lesson-files", {
|
await fetch("/api/admin/lesson-files", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ fileId }),
|
body: JSON.stringify({ fileId, url }),
|
||||||
});
|
});
|
||||||
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(f.id)}
|
onClick={() => handleDelete(f.id, f.url)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user