diff --git a/app/api/client/[token]/project/route.ts b/app/api/client/[token]/project/route.ts
index f811b1b..ce3e4fd 100644
--- a/app/api/client/[token]/project/route.ts
+++ b/app/api/client/[token]/project/route.ts
@@ -26,11 +26,18 @@ export async function GET(
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
- // Only return shots that have been explicitly shared with the client
+ // Only return shots that have been explicitly shared with the client.
+ // If the session has episode restrictions, further filter to those episodes.
+ const episodeFilter =
+ session.allowedEpisodes && session.allowedEpisodes.length > 0
+ ? session.allowedEpisodes
+ : undefined;
+
const shots = await db.shot.findMany({
where: {
projectId: session.projectId,
sharedWithClient: true,
+ ...(episodeFilter ? { episode: { in: episodeFilter } } : {}),
},
orderBy: [{ episode: "asc" }, { sequence: "asc" }, { shotCode: "asc" }],
select: {
diff --git a/app/api/projects/[projectId]/episodes/route.ts b/app/api/projects/[projectId]/episodes/route.ts
new file mode 100644
index 0000000..98ee54f
--- /dev/null
+++ b/app/api/projects/[projectId]/episodes/route.ts
@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+/** GET /api/projects/[projectId]/episodes — returns distinct episode values for shots in a project */
+export async function GET(
+ _req: NextRequest,
+ { params }: { params: Promise<{ projectId: string }> }
+) {
+ const session = await auth();
+ if (!session?.user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+
+ const shots = await db.shot.findMany({
+ where: { projectId, episode: { not: null } },
+ select: { episode: true },
+ distinct: ["episode"],
+ orderBy: { episode: "asc" },
+ });
+
+ const episodes = shots
+ .map((s) => s.episode as string)
+ .filter(Boolean);
+
+ return NextResponse.json({ episodes });
+}
diff --git a/app/api/review-sessions/route.ts b/app/api/review-sessions/route.ts
index beccbd6..3aee634 100644
--- a/app/api/review-sessions/route.ts
+++ b/app/api/review-sessions/route.ts
@@ -33,7 +33,7 @@ export async function POST(req: NextRequest) {
}
const body = await req.json();
- const { projectId, label, email, expiresInDays = 30, password } = body;
+ const { projectId, label, email, expiresInDays = 30, password, allowedEpisodes } = body;
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
@@ -49,6 +49,11 @@ export async function POST(req: NextRequest) {
? await bcrypt.hash(password, 12)
: null;
+ const episodeFilter =
+ Array.isArray(allowedEpisodes) && allowedEpisodes.length > 0
+ ? (allowedEpisodes as string[]).filter((e) => typeof e === "string" && e.length > 0)
+ : [];
+
const reviewSession = await db.reviewSession.create({
data: {
projectId,
@@ -56,6 +61,7 @@ export async function POST(req: NextRequest) {
email: email || null,
passwordHash,
expiresAt: addDays(new Date(), expiresInDays),
+ allowedEpisodes: episodeFilter,
},
});
diff --git a/components/clients/ReviewSessionList.tsx b/components/clients/ReviewSessionList.tsx
index ced71c3..ded07fa 100644
--- a/components/clients/ReviewSessionList.tsx
+++ b/components/clients/ReviewSessionList.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { ExternalLink, Copy, Check, Trash2, Clock } from "lucide-react";
+import { ExternalLink, Copy, Check, Trash2, Clock, Film } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn, formatRelativeDate } from "@/lib/utils";
@@ -15,6 +15,7 @@ interface ReviewSession {
expiresAt: Date | string | null;
accessCount: number;
isActive: boolean;
+ allowedEpisodes: string[];
project: { name: string };
}
@@ -101,7 +102,20 @@ export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
>
)}
-
{portalUrl}
+ {portalUrl}
+ {session.allowedEpisodes && session.allowedEpisodes.length > 0 && (
+
+
+ {session.allowedEpisodes.map((ep) => (
+
+ {ep}
+
+ ))}
+
+ )}
diff --git a/components/clients/ShareReviewDialog.tsx b/components/clients/ShareReviewDialog.tsx
index 256a80a..23dccf2 100644
--- a/components/clients/ShareReviewDialog.tsx
+++ b/components/clients/ShareReviewDialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -17,7 +17,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
-import { Copy, Check, ExternalLink, Eye, EyeOff, Lock } from "lucide-react";
+import { Copy, Check, ExternalLink, Eye, EyeOff, Lock, Film } from "lucide-react";
import {
Select,
SelectContent,
@@ -25,6 +25,319 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+
+const schema = z.object({
+ projectId: z.string().min(1, "Select a project"),
+ label: z.string().min(1, "Label is required"),
+ email: z.string().email("Invalid email"),
+ expiresInDays: z.number().int().positive().default(30),
+ password: z.string().optional(),
+});
+
+type FormValues = z.infer
;
+
+interface Project {
+ id: string;
+ name: string;
+ code: string;
+}
+
+interface ShareReviewDialogProps {
+ children: React.ReactNode;
+ clientId: string;
+ clientEmail: string;
+ projects: Project[];
+}
+
+export function ShareReviewDialog({
+ children,
+ clientEmail,
+ projects,
+}: ShareReviewDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [portalUrl, setPortalUrl] = useState(null);
+ const [isPasswordProtected, setIsPasswordProtected] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [episodes, setEpisodes] = useState([]);
+ const [selectedEpisodes, setSelectedEpisodes] = useState([]);
+ const [loadingEpisodes, setLoadingEpisodes] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ reset,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ projectId: projects[0]?.id ?? "",
+ label: "Review Round 1",
+ email: clientEmail,
+ expiresInDays: 30,
+ password: "",
+ },
+ });
+
+ const selectedProjectId = watch("projectId");
+
+ // Load episodes when project changes
+ useEffect(() => {
+ if (!selectedProjectId) return;
+ setSelectedEpisodes([]);
+ setLoadingEpisodes(true);
+ fetch(`/api/projects/${selectedProjectId}/episodes`)
+ .then((r) => r.json())
+ .then((data) => setEpisodes(data.episodes ?? []))
+ .catch(() => setEpisodes([]))
+ .finally(() => setLoadingEpisodes(false));
+ }, [selectedProjectId]);
+
+ const toggleEpisode = (ep: string) => {
+ setSelectedEpisodes((prev) =>
+ prev.includes(ep) ? prev.filter((e) => e !== ep) : [...prev, ep]
+ );
+ };
+
+ const handleCopy = async () => {
+ if (!portalUrl) return;
+ await navigator.clipboard.writeText(portalUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const handleReset = () => {
+ setPortalUrl(null);
+ setIsPasswordProtected(false);
+ setSelectedEpisodes([]);
+ reset({
+ projectId: projects[0]?.id ?? "",
+ label: "Review Round 1",
+ email: clientEmail,
+ expiresInDays: 30,
+ password: "",
+ });
+ };
+
+ const onSubmit = async (values: FormValues) => {
+ setLoading(true);
+ try {
+ const res = await fetch("/api/review-sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...values,
+ password: values.password?.trim() || undefined,
+ allowedEpisodes: selectedEpisodes.length > 0 ? selectedEpisodes : [],
+ }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error ?? "Failed to create review link");
+ }
+ const data = await res.json();
+ setPortalUrl(data.portalUrl);
+ setIsPasswordProtected(!!(values.password?.trim()));
+ router.refresh();
+ } catch (e) {
+ toast({
+ title: "Failed to create review link",
+ description: e instanceof Error ? e.message : undefined,
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
const schema = z.object({
projectId: z.string().min(1, "Select a project"),
diff --git a/prisma/migrations/20260624000000_add_allowed_episodes_to_review_session/migration.sql b/prisma/migrations/20260624000000_add_allowed_episodes_to_review_session/migration.sql
new file mode 100644
index 0000000..ac88810
--- /dev/null
+++ b/prisma/migrations/20260624000000_add_allowed_episodes_to_review_session/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "review_sessions" ADD COLUMN "allowedEpisodes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index eac5f2f..27ee48b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -527,16 +527,17 @@ model Task {
/// Secure tokenized review link for clients
model ReviewSession {
- id String @id @default(cuid())
- projectId String
- token String @unique @default(cuid())
- label String?
- email String?
- passwordHash String?
- expiresAt DateTime
- isActive Boolean @default(true)
- accessCount Int @default(0)
- createdAt DateTime @default(now())
+ id String @id @default(cuid())
+ projectId String
+ token String @unique @default(cuid())
+ label String?
+ email String?
+ passwordHash String?
+ expiresAt DateTime
+ isActive Boolean @default(true)
+ accessCount Int @default(0)
+ allowedEpisodes String[] @default([])
+ createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)