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:
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user