From 75d5149beafcd433c8e84da2dff68ec5d46e9d37 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:19:22 +0200 Subject: [PATCH] versioning added --- actions/shots.ts | 32 ++++++++ .../projects/[id]/shots/[shotId]/page.tsx | 77 ++++++++++++++++++- app/api/client/[token]/approve/route.ts | 10 ++- app/api/ext/shots/[shotId]/route.ts | 49 ++++++++++++ app/api/ext/shots/lookup/route.ts | 1 + .../migration.sql | 2 + prisma/schema.prisma | 2 + types/index.ts | 2 + 8 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260625110000_add_shot_version/migration.sql diff --git a/actions/shots.ts b/actions/shots.ts index d1f9f46..bdad131 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -970,3 +970,35 @@ export async function clientRequestShotChanges(shotId: string, taskId?: string) revalidatePath(`/shot-status`); return { success: true }; } + +// ── Shot Version ────────────────────────────────────────────────────────────── + +const VERSION_RE = /^v\d{3}$/; + +/** + * Set or manually override the shot's version string (format: v###). + */ +export async function updateShotVersion(shotId: string, version: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + if (!VERSION_RE.test(version)) { + throw new Error("Version must be in format v### (e.g. v001, v012)"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + await db.shot.update({ + where: { id: shotId }, + data: { shotVersion: version }, + }); + + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + return { success: true, shotVersion: version }; +} diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 7a1aa7e..9f4b32c 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -28,12 +28,15 @@ import { ExternalLink, XCircle, FileVideo, + Pencil, + Check, } from "lucide-react"; +import { Input } from "@/components/ui/input"; import type { ShotWithDetails } from "@/types"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { FootageViewer } from "@/components/shots/FootageViewer"; import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog"; -import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots"; +import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots"; const STATUS_CONFIG: Record< string, @@ -87,6 +90,9 @@ export default function ShotDetailPage() { const [isActioning, setIsActioning] = useState(false); const [highResDialogOpen, setHighResDialogOpen] = useState(false); const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks"); + const [editingVersion, setEditingVersion] = useState(false); + const [versionInput, setVersionInput] = useState(""); + const [savingVersion, setSavingVersion] = useState(false); const fetchShot = async () => { try { @@ -171,6 +177,26 @@ export default function ShotDetailPage() { } }; + const handleSaveVersion = async () => { + if (!shot) return; + const trimmed = versionInput.trim().toLowerCase(); + if (!/^v\d{3}$/.test(trimmed)) { + toast({ title: "Invalid format", description: "Version must be v### (e.g. v001)", variant: "destructive" }); + return; + } + setSavingVersion(true); + try { + await updateShotVersion(shot.id, trimmed); + toast({ title: `Version set to ${trimmed}` }); + setEditingVersion(false); + fetchShot(); + } catch (e) { + toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setSavingVersion(false); + } + }; + const handleDuplicate = async () => { if (!shot) return; setIsDuplicating(true); @@ -237,6 +263,55 @@ export default function ShotDetailPage() {

{shot.shotCode}

+ + {/* Shot version badge — click to edit for managers */} + {canManage ? ( + editingVersion ? ( +
+ setVersionInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveVersion(); + if (e.key === "Escape") setEditingVersion(false); + }} + className="h-7 w-20 font-mono text-xs px-2" + placeholder="v001" + maxLength={4} + /> + + +
+ ) : ( + + ) + ) : ( + + {shot.shotVersion ?? "v001"} + + )} + {shot.sequence && ( Seq: {shot.sequence} )} diff --git a/app/api/client/[token]/approve/route.ts b/app/api/client/[token]/approve/route.ts index 6af9ce9..aecdab9 100644 --- a/app/api/client/[token]/approve/route.ts +++ b/app/api/client/[token]/approve/route.ts @@ -38,7 +38,7 @@ export async function POST( if (shotId && action) { const shot = await db.shot.findUnique({ where: { id: shotId }, - select: { projectId: true, sharedWithClient: true }, + select: { projectId: true, sharedWithClient: true, shotVersion: true }, }); if (!shot || shot.projectId !== session.projectId) { return NextResponse.json({ error: "Shot not found" }, { status: 404 }); @@ -53,10 +53,16 @@ export async function POST( data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" }, }); } else if (action === "NEEDS_CHANGES") { + // Increment shotVersion: v001 → v002, v009 → v010, etc. + const currentVer = shot.shotVersion ?? "v001"; + const verMatch = currentVer.match(/^v(\d+)$/); + const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2; + const nextVersion = "v" + String(nextNum).padStart(3, "0"); + await db.$transaction(async (tx) => { await tx.shot.update({ where: { id: shotId }, - data: { shotApprovalStatus: "PENDING", sharedWithClient: false }, + data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion }, }); // Mark all non-DONE tasks as CHANGES await tx.task.updateMany({ diff --git a/app/api/ext/shots/[shotId]/route.ts b/app/api/ext/shots/[shotId]/route.ts index bef3d2b..4ea2166 100644 --- a/app/api/ext/shots/[shotId]/route.ts +++ b/app/api/ext/shots/[shotId]/route.ts @@ -57,6 +57,7 @@ export async function GET( exrOutput: true, seqTimecodeStart: true, seqTimecodeEnd: true, + shotVersion: true, createdAt: true, updatedAt: true, project: { @@ -102,3 +103,51 @@ export async function GET( return NextResponse.json({ shot }); } + +// ── PATCH /api/ext/shots/[shotId] ───────────────────────────────────────────── +// +// Update mutable shot fields from pipeline tools. +// Currently supports: shotVersion (format v###) +// +// Body (JSON): { shotVersion?: string } +// Returns: { success: true, shot: { id, shotVersion } } + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { shotId } = await params; + const body = await req.json().catch(() => ({})); + const { shotVersion } = body as { shotVersion?: string }; + + if (shotVersion !== undefined) { + if (!/^v\d{3}$/.test(shotVersion)) { + return NextResponse.json( + { error: "shotVersion must match format v### (e.g. v001)" }, + { status: 400 } + ); + } + } else { + return NextResponse.json({ error: "No updatable fields provided" }, { status: 400 }); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { id: true }, + }); + if (!shot) { + return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + } + + const updated = await db.shot.update({ + where: { id: shotId }, + data: { ...(shotVersion !== undefined && { shotVersion }) }, + select: { id: true, shotVersion: true }, + }); + + return NextResponse.json({ success: true, shot: updated }); +} diff --git a/app/api/ext/shots/lookup/route.ts b/app/api/ext/shots/lookup/route.ts index d994a45..8e56e35 100644 --- a/app/api/ext/shots/lookup/route.ts +++ b/app/api/ext/shots/lookup/route.ts @@ -65,6 +65,7 @@ export async function GET(req: NextRequest) { exrOutput: true, seqTimecodeStart: true, seqTimecodeEnd: true, + shotVersion: true, thumbnailUrl: true, createdAt: true, updatedAt: true, diff --git a/prisma/migrations/20260625110000_add_shot_version/migration.sql b/prisma/migrations/20260625110000_add_shot_version/migration.sql new file mode 100644 index 0000000..214c502 --- /dev/null +++ b/prisma/migrations/20260625110000_add_shot_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "shots" ADD COLUMN "shotVersion" TEXT NOT NULL DEFAULT 'v001'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b305ba3..b729048 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -309,6 +309,8 @@ model Shot { // High-res deliverable (stored in Hetzner Object Storage) highResKey String? highResFilename String? + // Shot-level version tracking (e.g. v001, v002) + shotVersion String @default("v001") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/types/index.ts b/types/index.ts index 88994b6..fd891e7 100644 --- a/types/index.ts +++ b/types/index.ts @@ -179,6 +179,8 @@ export interface ShotWithDetails { seqTimecodeEnd: string | null; // High-res deliverable highResFilename: string | null; + // Shot-level version tracking + shotVersion: string; shotGroup: { id: string; name: string } | null; footagePlates: { id: string;