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;