feat(clients): reactivate an expired client review link
Deploy / deploy (push) Successful in 3m16s

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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-25 05:38:42 +02:00
parent 390d7a21ac
commit 824695ef30
2 changed files with 96 additions and 1 deletions
+34
View File
@@ -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) {
+62 -1
View File
@@ -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<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [reactivatingId, setReactivatingId] = useState<string | null>(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) {
<span className="text-xs text-zinc-500 mr-2">
{session.accessCount} view{session.accessCount !== 1 ? "s" : ""}
</span>
{expired && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 gap-1.5 text-emerald-400 hover:bg-emerald-500/10 border-emerald-500/30"
disabled={reactivatingId === session.id}
>
{reactivatingId === session.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
Reactivate
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="text-xs text-zinc-500">Valid for</DropdownMenuLabel>
{[7, 14, 30, 60, 90].map((d) => (
<DropdownMenuItem key={d} onClick={() => handleReactivate(session.id, d)}>
{d} days
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
size="sm"
variant="outline"