@@ -123,6 +123,12 @@ export function TaskDetailClient({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
|
const [dueDateValue, setDueDateValue] = useState(
|
||||||
|
task.dueDate ? format(new Date(task.dueDate), "yyyy-MM-dd") : ""
|
||||||
|
);
|
||||||
|
const [estimatedHoursValue, setEstimatedHoursValue] = useState(
|
||||||
|
task.estimatedHours != null ? String(task.estimatedHours) : ""
|
||||||
|
);
|
||||||
|
|
||||||
const statusCfg = TASK_STATUS_CONFIG[task.status];
|
const statusCfg = TASK_STATUS_CONFIG[task.status];
|
||||||
const StatusIcon = statusCfg.icon;
|
const StatusIcon = statusCfg.icon;
|
||||||
@@ -147,6 +153,27 @@ export function TaskDetailClient({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDueDateChange = async (value: string) => {
|
||||||
|
setDueDateValue(value);
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { dueDate: value || null });
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to update due date", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEstimatedHoursBlur = async () => {
|
||||||
|
const parsed = estimatedHoursValue === "" ? null : parseFloat(estimatedHoursValue);
|
||||||
|
if (parsed !== null && (isNaN(parsed) || parsed <= 0)) return;
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { estimatedHours: parsed });
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to update estimated hours", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssigneeChange = async (artistId: string) => {
|
const handleAssigneeChange = async (artistId: string) => {
|
||||||
try {
|
try {
|
||||||
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
|
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
|
||||||
@@ -410,26 +437,51 @@ export function TaskDetailClient({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.dueDate && (
|
<div>
|
||||||
<div>
|
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
|
||||||
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
|
{canManage ? (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDateValue}
|
||||||
|
onChange={(e) => handleDueDateChange(e.target.value)}
|
||||||
|
className="w-full text-sm bg-transparent border border-border rounded-md px-2 py-1 text-zinc-300 focus:outline-none focus:ring-1 focus:ring-amber-500/50 [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
) : task.dueDate ? (
|
||||||
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
|
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
|
||||||
<CalendarDays className="h-3.5 w-3.5" />
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
{format(new Date(task.dueDate), "MMM d, yyyy")}
|
{format(new Date(task.dueDate), "MMM d, yyyy")}
|
||||||
{isOverdue && <span className="text-xs">(Overdue)</span>}
|
{isOverdue && <span className="text-xs">(Overdue)</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<p className="text-sm text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{task.estimatedHours && (
|
<div>
|
||||||
<div>
|
<p className="text-xs text-zinc-500 mb-1">Estimated Hours</p>
|
||||||
<p className="text-xs text-zinc-500 mb-1">Estimated</p>
|
{canManage ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
value={estimatedHoursValue}
|
||||||
|
onChange={(e) => setEstimatedHoursValue(e.target.value)}
|
||||||
|
onBlur={handleEstimatedHoursBlur}
|
||||||
|
placeholder="—"
|
||||||
|
className="w-full text-sm bg-transparent border border-border rounded-md px-2 py-1 text-zinc-300 focus:outline-none focus:ring-1 focus:ring-amber-500/50 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-zinc-500 shrink-0">h</span>
|
||||||
|
</div>
|
||||||
|
) : task.estimatedHours ? (
|
||||||
<p className="text-sm flex items-center gap-1.5">
|
<p className="text-sm flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 text-zinc-400" />
|
<Clock className="h-3.5 w-3.5 text-zinc-400" />
|
||||||
{task.estimatedHours}h
|
{task.estimatedHours}h
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<p className="text-sm text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { uploadFile } from "@/lib/storage";
|
||||||
|
import { ShotPriority } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
|
const apiKey = process.env.API_SECRET_KEY;
|
||||||
|
if (!apiKey) return false; // key not configured → deny all
|
||||||
|
|
||||||
|
const authHeader = req.headers.get("authorization") ?? "";
|
||||||
|
if (authHeader.startsWith("Bearer ")) {
|
||||||
|
return authHeader.slice(7) === apiKey;
|
||||||
|
}
|
||||||
|
// Also accept X-API-Key header
|
||||||
|
const headerKey = req.headers.get("x-api-key") ?? "";
|
||||||
|
return headerKey === apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const createShotSchema = z.object({
|
||||||
|
projectId: z.string().cuid(),
|
||||||
|
scene: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
||||||
|
episode: z.string().max(50).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
artistId: z.string().cuid().optional(),
|
||||||
|
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
|
||||||
|
fps: z.coerce.number().default(24),
|
||||||
|
frameStart: z.coerce.number().int().optional(),
|
||||||
|
frameEnd: z.coerce.number().int().optional(),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
thumbnailUrl: z.string().url().optional(),
|
||||||
|
shotGroupName: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/ext/shots ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fields: Record<string, string> = {};
|
||||||
|
let thumbnailFile: File | null = null;
|
||||||
|
|
||||||
|
const contentType = req.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
if (contentType.includes("multipart/form-data")) {
|
||||||
|
const formData = await req.formData();
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (key === "thumbnail" && value instanceof File) {
|
||||||
|
thumbnailFile = value;
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
fields[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// application/json
|
||||||
|
fields = await req.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate fields
|
||||||
|
const parsed = createShotSchema.parse({
|
||||||
|
projectId: fields.projectId,
|
||||||
|
scene: fields.scene,
|
||||||
|
episode: fields.episode || undefined,
|
||||||
|
description: fields.description || undefined,
|
||||||
|
artistId: fields.artistId || undefined,
|
||||||
|
priority: fields.priority || undefined,
|
||||||
|
fps: fields.fps || undefined,
|
||||||
|
frameStart: fields.frameStart || undefined,
|
||||||
|
frameEnd: fields.frameEnd || undefined,
|
||||||
|
dueDate: fields.dueDate || undefined,
|
||||||
|
thumbnailUrl: fields.thumbnailUrl || undefined,
|
||||||
|
shotGroupName: fields.shotGroupName || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scene = parsed.scene.toUpperCase();
|
||||||
|
const episode = parsed.episode?.toUpperCase() ?? null;
|
||||||
|
|
||||||
|
// Fetch project for showId and projectType
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: parsed.projectId },
|
||||||
|
select: { showId: true, projectType: true },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!project.showId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Project has no Show ID set. Please edit the project to add one." },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Episodic projects require episode
|
||||||
|
if (project.projectType === "EPISODIC" && !episode) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Episode is required for episodic projects." },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-increment shot number within scene (+ episode for episodic)
|
||||||
|
const scopeWhere = {
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
scene,
|
||||||
|
...(project.projectType === "EPISODIC" ? { episode } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxShot = await db.shot.findFirst({
|
||||||
|
where: scopeWhere,
|
||||||
|
orderBy: { shotNumber: "desc" },
|
||||||
|
select: { shotNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
|
||||||
|
const paddedNumber = shotNumber.toString().padStart(4, "0");
|
||||||
|
|
||||||
|
const shotCode =
|
||||||
|
project.projectType === "EPISODIC" && episode
|
||||||
|
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
|
||||||
|
: `${project.showId}_${scene}_${paddedNumber}`;
|
||||||
|
|
||||||
|
// Upload thumbnail if provided as a file
|
||||||
|
let thumbnailUrl = parsed.thumbnailUrl;
|
||||||
|
if (thumbnailFile) {
|
||||||
|
if (!thumbnailFile.type.startsWith("image/")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Thumbnail must be an image file" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const maxSize = 50 * 1024 * 1024; // 50 MB
|
||||||
|
if (thumbnailFile.size > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Thumbnail too large (max 50 MB)" },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await thumbnailFile.arrayBuffer());
|
||||||
|
const result = await uploadFile(buffer, thumbnailFile.name, thumbnailFile.type, "image");
|
||||||
|
thumbnailUrl = result.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve shot group
|
||||||
|
let shotGroupId: string | undefined;
|
||||||
|
if (parsed.shotGroupName?.trim()) {
|
||||||
|
const group = await db.shotGroup.upsert({
|
||||||
|
where: {
|
||||||
|
projectId_name: {
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
name: parsed.shotGroupName.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
shotGroupId = group.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shot = await db.shot.create({
|
||||||
|
data: {
|
||||||
|
shotCode,
|
||||||
|
scene,
|
||||||
|
episode,
|
||||||
|
shotNumber,
|
||||||
|
description: parsed.description,
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
artistId: parsed.artistId || undefined,
|
||||||
|
priority: parsed.priority,
|
||||||
|
fps: parsed.fps,
|
||||||
|
frameStart: parsed.frameStart,
|
||||||
|
frameEnd: parsed.frameEnd,
|
||||||
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
|
||||||
|
thumbnailUrl,
|
||||||
|
shotGroupId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shotCode: true,
|
||||||
|
scene: true,
|
||||||
|
episode: true,
|
||||||
|
shotNumber: true,
|
||||||
|
description: true,
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
fps: true,
|
||||||
|
frameStart: true,
|
||||||
|
frameEnd: true,
|
||||||
|
dueDate: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
projectId: true,
|
||||||
|
artistId: true,
|
||||||
|
shotGroupId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ shot }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: "Validation error", details: err.errors }, { status: 422 });
|
||||||
|
}
|
||||||
|
console.error("[POST /api/ext/shots]", err);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export default auth((req) => {
|
|||||||
// Allow token-gated client API routes (comments, approvals via review token)
|
// Allow token-gated client API routes (comments, approvals via review token)
|
||||||
if (pathname.startsWith("/api/client/")) return;
|
if (pathname.startsWith("/api/client/")) return;
|
||||||
|
|
||||||
|
// Allow external/scripting API routes (authenticated via API key header)
|
||||||
|
if (pathname.startsWith("/api/ext/")) return;
|
||||||
|
|
||||||
// Allow local file serving (needed for video playback in client portal)
|
// Allow local file serving (needed for video playback in client portal)
|
||||||
if (pathname.startsWith("/api/files/")) return;
|
if (pathname.startsWith("/api/files/")) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user