208 lines
6.5 KiB
TypeScript
208 lines
6.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import { auth } from "@/auth";
|
||
import { db } from "@/lib/db";
|
||
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
||
import { recalcShotStatus } from "@/lib/shot-status";
|
||
|
||
export const maxDuration = 300; // 5 min — large .mov high-res files
|
||
|
||
/**
|
||
* POST /api/batch-upload/upload
|
||
*
|
||
* Handles a single file upload from the batch upload flow.
|
||
*
|
||
* FormData fields:
|
||
* file – the binary file
|
||
* action – "new-version" | "rename-and-upload" | "create-task" | "update-highres"
|
||
* shotId – shot DB id (required for all actions)
|
||
* projectId – project DB id
|
||
* taskId – required for "new-version" and "rename-and-upload"
|
||
* fallbackTaskId – optional: the "Comp" task id to rename for "create-task"
|
||
* newTaskTitle – desired task title (filename without extension)
|
||
*/
|
||
export async function POST(
|
||
req: NextRequest
|
||
): Promise<NextResponse> {
|
||
const session = await auth();
|
||
if (!session?.user) {
|
||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
}
|
||
|
||
const formData = await req.formData();
|
||
const file = formData.get("file") as File | null;
|
||
const action = formData.get("action") as string | null;
|
||
const shotId = formData.get("shotId") as string | null;
|
||
const projectId = formData.get("projectId") as string | null;
|
||
const taskId = formData.get("taskId") as string | null;
|
||
const fallbackTaskId = formData.get("fallbackTaskId") as string | null;
|
||
const newTaskTitle = (formData.get("newTaskTitle") as string | null) ?? "";
|
||
|
||
if (!action || !shotId || !projectId) {
|
||
return NextResponse.json(
|
||
{ error: "Missing required fields: action, shotId, projectId" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// ── High-res (.mov) — file was uploaded directly to Hetzner via presigned URL ──
|
||
// The file never passes through this server or the Nginx proxy, avoiding
|
||
// HTTP 413 errors caused by client_max_body_size limits.
|
||
if (action === "update-highres") {
|
||
const preUploadedKey = formData.get("key") as string | null;
|
||
const preUploadedFileName = formData.get("fileName") as string | null;
|
||
|
||
if (!preUploadedKey || !preUploadedFileName) {
|
||
return NextResponse.json(
|
||
{ error: "key and fileName are required for update-highres" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const shot = await db.shot.findUnique({
|
||
where: { id: shotId },
|
||
select: { id: true, highResKey: true },
|
||
});
|
||
if (!shot) {
|
||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||
}
|
||
|
||
if (shot.highResKey) {
|
||
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
||
}
|
||
|
||
await db.shot.update({
|
||
where: { id: shotId },
|
||
data: { highResKey: preUploadedKey, highResFilename: preUploadedFileName },
|
||
});
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
action: "update-highres",
|
||
fileName: preUploadedFileName,
|
||
});
|
||
}
|
||
|
||
// For all other actions (MP4 version uploads) a file is required.
|
||
if (!file) {
|
||
return NextResponse.json({ error: "file is required" }, { status: 400 });
|
||
}
|
||
|
||
const buffer = Buffer.from(await file.arrayBuffer());
|
||
|
||
// Browsers (especially on Windows) often send an empty MIME type for .mov
|
||
// files. Fall back to a safe content type based on the file extension.
|
||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||
const contentType =
|
||
file.type ||
|
||
(ext === "mov" ? "video/quicktime" :
|
||
ext === "mp4" ? "video/mp4" :
|
||
"application/octet-stream");
|
||
|
||
// ── Version upload (.mp4) ─────────────────────────────────────────────────
|
||
let resolvedTaskId: string;
|
||
|
||
if (action === "new-version") {
|
||
if (!taskId) {
|
||
return NextResponse.json(
|
||
{ error: "taskId is required for new-version action" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
resolvedTaskId = taskId;
|
||
} else if (action === "rename-and-upload") {
|
||
if (!taskId) {
|
||
return NextResponse.json(
|
||
{ error: "taskId is required for rename-and-upload action" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
// Rename the task to reflect the new version number
|
||
await db.task.update({
|
||
where: { id: taskId },
|
||
data: { title: newTaskTitle },
|
||
});
|
||
resolvedTaskId = taskId;
|
||
} else if (action === "create-task") {
|
||
if (fallbackTaskId) {
|
||
// Rename the existing "Comp" (or similar) task and reuse it
|
||
await db.task.update({
|
||
where: { id: fallbackTaskId },
|
||
data: { title: newTaskTitle },
|
||
});
|
||
resolvedTaskId = fallbackTaskId;
|
||
} else {
|
||
// Create a brand-new task under the shot
|
||
const lastTask = await db.task.findFirst({
|
||
where: { shotId },
|
||
orderBy: { sortOrder: "desc" },
|
||
select: { sortOrder: true },
|
||
});
|
||
const task = await db.task.create({
|
||
data: {
|
||
title: newTaskTitle,
|
||
type: "COMP",
|
||
shotId,
|
||
projectId,
|
||
createdById: session.user.id,
|
||
sortOrder: (lastTask?.sortOrder ?? -1) + 1,
|
||
},
|
||
});
|
||
resolvedTaskId = task.id;
|
||
}
|
||
} else {
|
||
return NextResponse.json(
|
||
{ error: `Unknown action: ${action}` },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// Upload the video to Hetzner object storage
|
||
const { key: videoKey } = await uploadToHetzner(buffer, file.name, contentType, "videos");
|
||
const result = { url: `/api/files/${videoKey}`, key: videoKey };
|
||
|
||
// Mark all existing versions for this task as no longer latest
|
||
await db.version.updateMany({
|
||
where: { taskId: resolvedTaskId },
|
||
data: { isLatest: false },
|
||
});
|
||
|
||
// Determine next sequential version number
|
||
const lastVersion = await db.version.findFirst({
|
||
where: { taskId: resolvedTaskId },
|
||
orderBy: { versionNumber: "desc" },
|
||
select: { versionNumber: true },
|
||
});
|
||
const versionNumber = (lastVersion?.versionNumber ?? 0) + 1;
|
||
|
||
// Create the version record
|
||
const version = await db.version.create({
|
||
data: {
|
||
versionNumber,
|
||
taskId: resolvedTaskId,
|
||
artistId: session.user.id,
|
||
fileUrl: result.url,
|
||
fileName: file.name,
|
||
fileSize: BigInt(file.size),
|
||
mimeType: file.type,
|
||
isLatest: true,
|
||
},
|
||
});
|
||
|
||
// Move the task to INTERNAL_REVIEW
|
||
await db.task.update({
|
||
where: { id: resolvedTaskId },
|
||
data: { status: "INTERNAL_REVIEW" },
|
||
});
|
||
|
||
// Recalculate the parent shot's status
|
||
await recalcShotStatus(shotId).catch(() => {});
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
action,
|
||
versionId: version.id,
|
||
versionNumber,
|
||
taskId: resolvedTaskId,
|
||
});
|
||
}
|