From bb7368ab065012339f47461917a7035c06cf93a5 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:20:33 +0200 Subject: [PATCH] client review by episode filter added --- app/api/client/[token]/project/route.ts | 9 +- .../projects/[projectId]/episodes/route.ts | 29 ++ app/api/review-sessions/route.ts | 8 +- components/clients/ReviewSessionList.tsx | 18 +- components/clients/ShareReviewDialog.tsx | 317 +++++++++++++++++- .../migration.sql | 2 + prisma/schema.prisma | 21 +- 7 files changed, 388 insertions(+), 16 deletions(-) create mode 100644 app/api/projects/[projectId]/episodes/route.ts create mode 100644 prisma/migrations/20260624000000_add_allowed_episodes_to_review_session/migration.sql 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 ( + { + if (!o) { setPortalUrl(null); } + setOpen(o); + }} + > + {children} + + + Share Review Link + + + {portalUrl ? ( +
+

+ Your review link is ready. Copy it and share it with your client. +

+ {isPasswordProtected && ( +
+ + This link is password protected. Share the password separately. +
+ )} + {selectedEpisodes.length > 0 && ( +
+ + Scoped to {selectedEpisodes.length === 1 ? "episode" : "episodes"}: {selectedEpisodes.join(", ")} +
+ )} +
+ + {portalUrl} + +
+ + + + +
+ ) : ( +
+
+ + + {errors.projectId &&

{errors.projectId.message}

} +
+ + {/* Episode filter — only shown when the project has episodes */} + {!loadingEpisodes && episodes.length > 0 && ( +
+ +
+ {episodes.map((ep) => { + const active = selectedEpisodes.includes(ep); + return ( + + ); + })} +
+ {selectedEpisodes.length > 0 && ( +

+ Showing shots from: {selectedEpisodes.join(", ")} +

+ )} +
+ )} + +
+ + + {errors.label &&

{errors.label.message}

} +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + +
+
+ +
+ + +
+
+ + + + +
+ )} +
+
+ ); +} + 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)