From 824695ef30c8de4aeb27f0e8c1afe6a8dd6c81d0 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:38:42 +0200 Subject: [PATCH] feat(clients): reactivate an expired client review link Expired links on the client page get a Reactivate button (7-90 days). PATCH /api/review-sessions pushes the expiry out and turns the link back on with the same token, so the URL the client already has works again. Staff roles only, as for creating a link. Co-Authored-By: Claude Opus 5.5 --- app/api/review-sessions/route.ts | 34 +++++++++++++ components/clients/ReviewSessionList.tsx | 63 +++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/app/api/review-sessions/route.ts b/app/api/review-sessions/route.ts index 3aee634..4f53e89 100644 --- a/app/api/review-sessions/route.ts +++ b/app/api/review-sessions/route.ts @@ -73,6 +73,40 @@ export async function POST(req: NextRequest) { return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 }); } +/** + * Reactivate a review link: push its expiry `expiresInDays` from now and turn + * it back on. The token is unchanged, so a URL the client already has works + * again rather than a new one having to be sent. + */ +export async function PATCH(req: NextRequest) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const id = req.nextUrl.searchParams.get("id"); + if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); + + const body = await req.json().catch(() => ({})); + const expiresInDays = Number(body.expiresInDays ?? 30); + if (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 365) { + return NextResponse.json({ error: "expiresInDays must be 1–365" }, { status: 400 }); + } + + const existing = await db.reviewSession.findUnique({ where: { id }, select: { id: true } }); + if (!existing) return NextResponse.json({ error: "Review link not found" }, { status: 404 }); + + const reviewSession = await db.reviewSession.update({ + where: { id }, + data: { expiresAt: addDays(new Date(), expiresInDays), isActive: true }, + }); + + return NextResponse.json({ session: reviewSession }); +} + export async function DELETE(req: NextRequest) { const session = await auth(); if (!session?.user) { diff --git a/components/clients/ReviewSessionList.tsx b/components/clients/ReviewSessionList.tsx index ded07fa..b2cf50d 100644 --- a/components/clients/ReviewSessionList.tsx +++ b/components/clients/ReviewSessionList.tsx @@ -2,8 +2,15 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { ExternalLink, Copy, Check, Trash2, Clock, Film } from "lucide-react"; +import { ExternalLink, Copy, Check, Trash2, Clock, Film, RotateCcw, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useToast } from "@/components/ui/use-toast"; import { cn, formatRelativeDate } from "@/lib/utils"; @@ -28,9 +35,36 @@ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? ""; export function ReviewSessionList({ sessions }: ReviewSessionListProps) { const [copiedId, setCopiedId] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [reactivatingId, setReactivatingId] = useState(null); const { toast } = useToast(); const router = useRouter(); + // Same token, new expiry — the URL the client already has starts working again. + const handleReactivate = async (id: string, days: number) => { + setReactivatingId(id); + try { + const res = await fetch(`/api/review-sessions?id=${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expiresInDays: days }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error); + } + toast({ title: "Review link reactivated", description: `Valid for ${days} more days — same URL.` }); + router.refresh(); + } catch (e) { + toast({ + title: "Failed to reactivate link", + description: e instanceof Error && e.message ? e.message : undefined, + variant: "destructive", + }); + } finally { + setReactivatingId(null); + } + }; + const handleCopy = async (token: string, id: string) => { const url = `${APP_URL}/client/${token}`; await navigator.clipboard.writeText(url); @@ -122,6 +156,33 @@ export function ReviewSessionList({ sessions }: ReviewSessionListProps) { {session.accessCount} view{session.accessCount !== 1 ? "s" : ""} + {expired && ( + + + + + + Valid for + {[7, 14, 30, 60, 90].map((d) => ( + handleReactivate(session.id, d)}> + {d} days + + ))} + + + )}