@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { BatchUploadClient } from "@/components/batch-upload/BatchUploadClient";
|
||||
|
||||
export const metadata = { title: "Batch Upload — VFX Review" };
|
||||
|
||||
export default async function BatchUploadPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
select: { id: true, name: true, code: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return <BatchUploadClient projects={projects} />;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
parseFilename,
|
||||
stripVersionFromTitle,
|
||||
findMatchingShotCode,
|
||||
} from "@/lib/batch-upload-parser";
|
||||
|
||||
export type PreviewItemStatus =
|
||||
| "new-version" // task found, same version prefix → just add a version
|
||||
| "rename-and-upload" // task found but different version → rename task + add version
|
||||
| "create-task" // no matching task → use/rename fallback "Comp" task or create new
|
||||
| "update-highres" // .mov file → replace the shot's high-res file
|
||||
| "no-shot" // shot code not found in this project
|
||||
| "unsupported"; // file extension is not .mp4 or .mov
|
||||
|
||||
export interface PreviewItem {
|
||||
fileName: string;
|
||||
status: PreviewItemStatus;
|
||||
shotId: string | null;
|
||||
shotCode: string | null;
|
||||
/** For .mp4: ID of the directly matched task, if found */
|
||||
taskId: string | null;
|
||||
/** For .mp4: current title of the matched / fallback task */
|
||||
currentTaskTitle: string | null;
|
||||
/** For .mp4: desired task title after upload (filename without extension) */
|
||||
newTaskTitle: string;
|
||||
/** For .mov: filename of the existing high-res file that will be replaced */
|
||||
currentHighResFilename: string | null;
|
||||
/** For create-task: ID of a "Comp" task to rename & use */
|
||||
fallbackTaskId: string | null;
|
||||
fallbackTaskTitle: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/preview
|
||||
* Body: { projectId: string; fileNames: string[] }
|
||||
*
|
||||
* Returns a preview of what action will be taken for each file, without
|
||||
* actually uploading anything.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectId, fileNames } = body as {
|
||||
projectId?: string;
|
||||
fileNames?: unknown;
|
||||
};
|
||||
|
||||
if (!projectId || !Array.isArray(fileNames) || fileNames.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId and fileNames[] are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Load all shots for this project with their tasks
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
highResFilename: true,
|
||||
tasks: {
|
||||
select: { id: true, title: true, type: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const shotCodes = shots.map((s) => s.shotCode);
|
||||
|
||||
const items: PreviewItem[] = fileNames.map((fileName: string) => {
|
||||
const parsed = parseFilename(fileName);
|
||||
|
||||
if (parsed.type === "other") {
|
||||
return {
|
||||
fileName,
|
||||
status: "unsupported" as const,
|
||||
shotId: null,
|
||||
shotCode: null,
|
||||
taskId: null,
|
||||
currentTaskTitle: null,
|
||||
newTaskTitle: "",
|
||||
currentHighResFilename: null,
|
||||
fallbackTaskId: null,
|
||||
fallbackTaskTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the shot whose code is a prefix of this file's task name base
|
||||
const matchedCode = findMatchingShotCode(parsed.taskNameBase, shotCodes);
|
||||
const matchedShot = matchedCode
|
||||
? shots.find((s) => s.shotCode === matchedCode) ?? null
|
||||
: null;
|
||||
|
||||
if (!matchedShot) {
|
||||
return {
|
||||
fileName,
|
||||
status: "no-shot" as const,
|
||||
shotId: null,
|
||||
shotCode: null,
|
||||
taskId: null,
|
||||
currentTaskTitle: null,
|
||||
newTaskTitle: "",
|
||||
currentHighResFilename: null,
|
||||
fallbackTaskId: null,
|
||||
fallbackTaskTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── .mov → update-highres ─────────────────────────────────────────────
|
||||
if (parsed.type === "mov") {
|
||||
return {
|
||||
fileName,
|
||||
status: "update-highres" as const,
|
||||
shotId: matchedShot.id,
|
||||
shotCode: matchedShot.shotCode,
|
||||
taskId: null,
|
||||
currentTaskTitle: null,
|
||||
newTaskTitle: "",
|
||||
currentHighResFilename: matchedShot.highResFilename ?? null,
|
||||
fallbackTaskId: null,
|
||||
fallbackTaskTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── .mp4 → find matching task ─────────────────────────────────────────
|
||||
const desiredTitle = parsed.taskNameWithVersion; // full name without ext
|
||||
|
||||
// Match by stripping version from existing task titles
|
||||
const matchedTask = matchedShot.tasks.find(
|
||||
(t) => stripVersionFromTitle(t.title) === parsed.taskNameBase
|
||||
);
|
||||
|
||||
if (matchedTask) {
|
||||
const sameTitle = matchedTask.title === desiredTitle;
|
||||
return {
|
||||
fileName,
|
||||
status: (sameTitle ? "new-version" : "rename-and-upload") as PreviewItemStatus,
|
||||
shotId: matchedShot.id,
|
||||
shotCode: matchedShot.shotCode,
|
||||
taskId: matchedTask.id,
|
||||
currentTaskTitle: matchedTask.title,
|
||||
newTaskTitle: desiredTitle,
|
||||
currentHighResFilename: null,
|
||||
fallbackTaskId: null,
|
||||
fallbackTaskTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
// No direct match – look for a generic "Comp" or "comp" task, or any COMP type
|
||||
const compTask = matchedShot.tasks.find(
|
||||
(t) =>
|
||||
t.title.toLowerCase() === "comp" ||
|
||||
t.type === "COMP"
|
||||
);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
status: "create-task" as const,
|
||||
shotId: matchedShot.id,
|
||||
shotCode: matchedShot.shotCode,
|
||||
taskId: null,
|
||||
currentTaskTitle: null,
|
||||
newTaskTitle: desiredTitle,
|
||||
currentHighResFilename: null,
|
||||
fallbackTaskId: compTask?.id ?? null,
|
||||
fallbackTaskTitle: compTask?.title ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadFile, uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
/**
|
||||
* 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 (!file || !action || !shotId || !projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: file, action, shotId, projectId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// ── High-res (.mov) ───────────────────────────────────────────────────────
|
||||
if (action === "update-highres") {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Remove the previous high-res file if one exists
|
||||
if (shot.highResKey) {
|
||||
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
||||
}
|
||||
|
||||
const { key } = await uploadToHetzner(buffer, file.name, file.type, "highres");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { highResKey: key, highResFilename: file.name },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: "update-highres",
|
||||
fileName: file.name,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 the configured storage backend
|
||||
const result = await uploadFile(buffer, file.name, file.type, "videos");
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user