Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { ApprovalStatus } from "@prisma/client";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { versionId, status, notes } = body;
|
||||
|
||||
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
|
||||
if (!versionId || !validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure the version belongs to this project via its task
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
task: {
|
||||
include: { shot: true, project: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projectId = version?.task?.projectId;
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
|
||||
// Record approval
|
||||
await db.approval.create({
|
||||
data: { versionId, userId: user.id, status, notes },
|
||||
});
|
||||
|
||||
// Update version approval status
|
||||
await db.version.update({
|
||||
where: { id: versionId },
|
||||
data: { approvalStatus: status },
|
||||
});
|
||||
|
||||
// Update task status based on approval decision
|
||||
if (version.task) {
|
||||
if (status === "APPROVED") {
|
||||
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
|
||||
} else {
|
||||
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
|
||||
}
|
||||
|
||||
// Recalculate derived shot status
|
||||
if (version.task.shot) {
|
||||
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Slack notification
|
||||
if (version.task?.project?.slackWebhook) {
|
||||
const { slackNotifyApproval } = await import("@/lib/slack");
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||
const contextCode = version.task.shot?.shotCode ?? version.task.title;
|
||||
await slackNotifyApproval(version.task.project.slackWebhook, {
|
||||
shotCode: contextCode,
|
||||
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
|
||||
reviewerName: user.name ?? "Client",
|
||||
status,
|
||||
projectName: version.task.project.name,
|
||||
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
|
||||
/** Find or create a guest user for the client reviewer based on the session email */
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { versionId, frameNumber, timestamp, text } = body;
|
||||
|
||||
if (!versionId || frameNumber == null || timestamp == null || !text?.trim()) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure the version belongs to this project
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
|
||||
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||
},
|
||||
});
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Resolve commenter identity
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
|
||||
const comment = await db.comment.create({
|
||||
data: {
|
||||
versionId,
|
||||
authorId: user.id,
|
||||
frameNumber,
|
||||
timestamp,
|
||||
text: text.trim(),
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Slack notification
|
||||
const slackWebhook =
|
||||
version.shot?.project?.slackWebhook ?? version.task?.project?.slackWebhook ?? null;
|
||||
const shotCode =
|
||||
version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.title ?? "Task";
|
||||
if (slackWebhook) {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
await slackNotifyNewFeedback(slackWebhook, {
|
||||
shotCode,
|
||||
frameNumber,
|
||||
authorName: user.name ?? user.email,
|
||||
commentText: text.trim(),
|
||||
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ comment }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: session.projectId },
|
||||
select: { id: true, name: true, code: true, description: true, status: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find shots that have at least one task with a client-visible version
|
||||
const shots = await db.shot.findMany({
|
||||
where: {
|
||||
projectId: session.projectId,
|
||||
tasks: {
|
||||
some: {
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
sequence: true,
|
||||
description: true,
|
||||
status: true,
|
||||
thumbnailUrl: true,
|
||||
tasks: {
|
||||
where: {
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
versions: {
|
||||
where: { isClientVisible: true, isLatest: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
thumbnailUrl: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Asset tasks with client-visible versions (no shotId)
|
||||
const assetTasks = await db.task.findMany({
|
||||
where: {
|
||||
projectId: session.projectId,
|
||||
shotId: null,
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
versions: {
|
||||
where: { isClientVisible: true, isLatest: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
thumbnailUrl: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Increment access count
|
||||
await db.reviewSession.update({
|
||||
where: { id: session.id },
|
||||
data: { accessCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
project,
|
||||
shots,
|
||||
assetTasks,
|
||||
sessionLabel: session.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string; versionId: string }> }
|
||||
) {
|
||||
const { token, versionId } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
isLatest: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
task: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
shot: { select: { shotCode: true } },
|
||||
asset: { select: { assetCode: true, name: true } },
|
||||
},
|
||||
},
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
approvals: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve project: from shot or from task
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// For task-only versions (no shot), require explicit client sharing
|
||||
if (!version.shot && !version.isClientVisible) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const comments = await db.comment.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const serializedVersion = {
|
||||
...version,
|
||||
fileSize: version.fileSize?.toString() ?? null,
|
||||
};
|
||||
|
||||
return NextResponse.json({ version: serializedVersion, comments });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ clientId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { clientId } = await params;
|
||||
|
||||
const client = await db.client.findUnique({
|
||||
where: { id: clientId },
|
||||
include: {
|
||||
projects: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_count: { select: { shots: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: "Client not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ client });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ clientId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { clientId } = await params;
|
||||
const body = await req.json();
|
||||
const { company, contactPerson, email, phone, notes, isActive } = body;
|
||||
|
||||
const client = await db.client.update({
|
||||
where: { id: clientId },
|
||||
data: { company, contactPerson, email, phone, notes, isActive },
|
||||
});
|
||||
|
||||
return NextResponse.json({ client });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
company: true,
|
||||
contactPerson: true,
|
||||
email: true,
|
||||
isActive: true,
|
||||
_count: { select: { projects: true } },
|
||||
},
|
||||
orderBy: { company: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ clients });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { company, contactPerson, email, phone, notes } = body;
|
||||
|
||||
if (!company || !contactPerson || !email) {
|
||||
return NextResponse.json({ error: "company, contactPerson and email are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await db.client.create({
|
||||
data: { company, contactPerson, email, phone, notes },
|
||||
});
|
||||
|
||||
return NextResponse.json({ client }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string[] }> }
|
||||
) {
|
||||
const { key } = await params;
|
||||
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
|
||||
const relativePath = key.join("/");
|
||||
|
||||
// Sanitize: prevent path traversal
|
||||
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
||||
const filePath = path.resolve(path.join(uploadDir, relativePath));
|
||||
|
||||
if (!filePath.startsWith(uploadDir)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".mxf": "application/mxf",
|
||||
".webm": "video/webm",
|
||||
};
|
||||
const contentType = mimeMap[ext] ?? "application/octet-stream";
|
||||
|
||||
// Support range requests so the HTML5 video player can seek
|
||||
const rangeHeader = req.headers.get("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
const stream = fs.createReadStream(filePath, { start, end });
|
||||
const nodeStream = stream as unknown as ReadableStream;
|
||||
|
||||
return new NextResponse(nodeStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
"Content-Length": String(stat.size),
|
||||
"Content-Type": contentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const notifications = await db.notification.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const unreadCount = await db.notification.count({
|
||||
where: { userId: session.user.id, isRead: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ notifications, unreadCount });
|
||||
}
|
||||
|
||||
export async function PATCH() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
await db.notification.updateMany({
|
||||
where: { userId: session.user.id, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const status = searchParams.get("status");
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ code: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
status ? { status: status as any } : {},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
client: { select: { id: true, company: true } },
|
||||
producer: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { shots: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ projects });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { addDays } from "date-fns";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const projectId = req.nextUrl.searchParams.get("projectId");
|
||||
|
||||
const sessions = await db.reviewSession.findMany({
|
||||
where: projectId ? { projectId } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ sessions });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { projectId, label, email, expiresInDays = 30 } = body;
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({ where: { id: projectId } });
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const reviewSession = await db.reviewSession.create({
|
||||
data: {
|
||||
projectId,
|
||||
label: label || `Review — ${project.name}`,
|
||||
email: email || null,
|
||||
expiresAt: addDays(new Date(), expiresInDays),
|
||||
},
|
||||
});
|
||||
|
||||
const appUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
`${req.headers.get("x-forwarded-proto") ?? "https"}://${req.headers.get("host")}`;
|
||||
const portalUrl = `${appUrl}/client/${reviewSession.token}`;
|
||||
|
||||
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
await db.reviewSession.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shotId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shotId } = await params;
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { comments: true } },
|
||||
approvals: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true, image: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!shot) {
|
||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: shot.projectId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const [tasks, artists] = await Promise.all([
|
||||
db.task.findMany({
|
||||
where: { shotId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { versions: true } },
|
||||
versions: {
|
||||
take: 1,
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, name: true, email: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
|
||||
session.user.role as string
|
||||
);
|
||||
|
||||
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
|
||||
const shotSerialized = {
|
||||
...shot,
|
||||
versions: shot.versions.map((v) => ({
|
||||
...v,
|
||||
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
|
||||
})),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
shot: shotSerialized,
|
||||
projectName: project?.name ?? "",
|
||||
canApprove,
|
||||
tasks,
|
||||
artists,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { taskId } = await params;
|
||||
const task = await db.task.findUnique({
|
||||
where: { id: taskId },
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const task = await db.task.update({
|
||||
where: { id: taskId },
|
||||
data: body,
|
||||
});
|
||||
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
await db.task.delete({ where: { id: taskId } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get("projectId");
|
||||
const shotId = searchParams.get("shotId");
|
||||
const assetId = searchParams.get("assetId");
|
||||
|
||||
const tasks = await db.task.findMany({
|
||||
where: {
|
||||
...(projectId && { projectId }),
|
||||
...(shotId && { shotId }),
|
||||
...(assetId && { assetId }),
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tasks);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { uploadFile } from "@/lib/storage";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
// Max 2 GB
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!file.type.match(/video\//)) {
|
||||
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File too large (max 2 GB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const result = await uploadFile(buffer, file.name, file.type, "videos");
|
||||
|
||||
return NextResponse.json({ url: result.url, key: result.key });
|
||||
} catch (err) {
|
||||
console.error("[local-upload]", err);
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { uploadFile } from "@/lib/storage";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = (formData.get("type") as string) || "videos";
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type based on upload type
|
||||
if (type === "image" && !file.type.match(/image\//)) {
|
||||
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type === "video" && !file.type.match(/video\//)) {
|
||||
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Size limit: 500MB for images, 2GB for videos
|
||||
const maxSize = type === "image" ? 500 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeStr = type === "image" ? "500 MB" : "2 GB";
|
||||
return NextResponse.json(
|
||||
{ error: `File too large (max ${maxSizeStr})` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const result = await uploadFile(buffer, file.name, file.type, type);
|
||||
|
||||
return NextResponse.json({ url: result.url, key: result.key });
|
||||
} catch (err) {
|
||||
console.error("[upload]", err);
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const disabled = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"UploadThing is not configured. Add UPLOADTHING_SECRET to .env or use STORAGE_PROVIDER=local.",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
|
||||
// Lazily resolve the real handlers only when the secret is present.
|
||||
// This prevents the UploadThing SDK from throwing at module-load time
|
||||
// when the env var is missing (e.g. STORAGE_PROVIDER=local).
|
||||
async function getHandlers() {
|
||||
if (!process.env.UPLOADTHING_SECRET) return null;
|
||||
const { createRouteHandler } = await import("uploadthing/next");
|
||||
const { uploadRouter } = await import("@/lib/uploadthing");
|
||||
return createRouteHandler({ router: uploadRouter });
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const h = await getHandlers();
|
||||
return h ? h.GET(req) : disabled();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const h = await getHandlers();
|
||||
return h ? h.POST(req) : disabled();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
|
||||
const annotations = await db.annotation.findMany({
|
||||
where: { versionId, isVisible: true },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
orderBy: { frameNumber: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ annotations });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
|
||||
const comments = await db.comment.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ comments });
|
||||
}
|
||||
Reference in New Issue
Block a user