diff --git a/app/api/client/[token]/annotation/route.ts b/app/api/client/[token]/annotation/route.ts new file mode 100644 index 0000000..db755a6 --- /dev/null +++ b/app/api/client/[token]/annotation/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { validateReviewToken } from "@/lib/review-auth"; + +/** Find or create a guest user for the client reviewer based on the session email */ +async function getOrCreateClientUser(email: string, label?: string | null) { + const existing = await db.user.findUnique({ where: { email } }); + if (existing) return existing; + return db.user.create({ + data: { + email, + name: label ?? email.split("@")[0], + role: "CLIENT", + isActive: true, + }, + }); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + const result = await validateReviewToken(token, req); + if (result.type === "requiresPassword") { + return NextResponse.json({ requiresPassword: true }, { status: 401 }); + } + if (result.type === "invalid") { + return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); + } + const session = result.session; + + const body = await req.json(); + const { versionId, frameNumber, drawingData, color } = body; + + if (!versionId || frameNumber == null || !drawingData) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + // Ensure the version belongs to this project + const version = await db.version.findUnique({ + where: { id: versionId }, + include: { + shot: { select: { projectId: true } }, + task: { select: { projectId: true } }, + }, + }); + const projectId = version?.shot?.projectId ?? version?.task?.projectId; + if (!version || projectId !== session.projectId) { + return NextResponse.json({ error: "Version not found" }, { status: 404 }); + } + + // Resolve commenter identity + const email = session.email ?? `client+${token.slice(0, 8)}@review.external`; + const user = await getOrCreateClientUser(email, session.label); + + const annotation = await db.annotation.create({ + data: { + versionId, + authorId: user.id, + frameNumber, + drawingData, + color: color ?? "#ef4444", + }, + }); + + return NextResponse.json({ success: true, annotation }); +} diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx index 6cdf6f3..45a3d13 100644 --- a/app/client/[token]/review/[versionId]/page.tsx +++ b/app/client/[token]/review/[versionId]/page.tsx @@ -445,6 +445,23 @@ export default function ClientReviewPage({ comments={comments as any} versionId={version.id} onAddComment={handlePlayerAddComment} + saveAnnotationFn={async (data) => { + const res = await fetch(`/api/client/${token}/annotation`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to save annotation"); + }} + addCommentFn={async (data) => { + const res = await fetch(`/api/client/${token}/comment`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to post comment"); + await refreshComments(token, version.id); + }} /> diff --git a/components/annotations/AnnotationCanvas.tsx b/components/annotations/AnnotationCanvas.tsx index 994da22..d343d2c 100644 --- a/components/annotations/AnnotationCanvas.tsx +++ b/components/annotations/AnnotationCanvas.tsx @@ -14,6 +14,20 @@ import { useToast } from "@/components/ui/use-toast"; import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types"; import { v4 as uuidv4 } from "uuid"; +interface SaveAnnotationData { + versionId: string; + frameNumber: number; + drawingData: AnnotationDrawingData; + color: string; +} + +interface AddCommentData { + versionId: string; + frameNumber: number; + timestamp: number; + text: string; +} + interface AnnotationCanvasProps { versionId: string; frameNumber: number; @@ -22,6 +36,10 @@ interface AnnotationCanvasProps { showAnnotations: boolean; existingAnnotations?: unknown[]; onAnnotationSaved?: (frameNumber: number) => void; + /** Override the default server action used to persist annotations (e.g. for client-token routes) */ + saveAnnotationFn?: (data: SaveAnnotationData) => Promise; + /** Override the default server action used to create the companion comment */ + addCommentFn?: (data: AddCommentData) => Promise; } type DrawingState = { @@ -38,6 +56,8 @@ export function AnnotationCanvas({ showAnnotations, existingAnnotations = [], onAnnotationSaved, + saveAnnotationFn, + addCommentFn, }: AnnotationCanvasProps) { const canvasRef = useRef(null); const { selectedTool, selectedColor, strokeWidth } = useReviewStore(); @@ -334,7 +354,16 @@ export function AnnotationCanvas({ canvasHeight: canvas?.offsetHeight ?? 1080, version: "1.0", }; - await saveAnnotation({ + + const doSaveAnnotation = saveAnnotationFn + ? saveAnnotationFn + : (data: SaveAnnotationData) => saveAnnotation(data).then(() => undefined); + + const doAddComment = addCommentFn + ? addCommentFn + : (data: AddCommentData) => addComment(data).then(() => undefined); + + await doSaveAnnotation({ versionId, frameNumber, drawingData, @@ -344,7 +373,7 @@ export function AnnotationCanvas({ // Create a companion comment ONCE per frame so the annotation appears in the panel. // Additional strokes on the same frame update the existing comment count silently. if (!annotationCommentedFrames.has(frameNumber)) { - await addComment({ + await doAddComment({ versionId, frameNumber, timestamp: frameNumber / fps, @@ -360,7 +389,7 @@ export function AnnotationCanvas({ variant: "destructive", }); } - }, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]); + }, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast, saveAnnotationFn, addCommentFn]); // ── Touch events (mirrors mouse events for mobile drawing) ────────────── const handleTouchStart = useCallback( diff --git a/components/player/ReviewPlayer.tsx b/components/player/ReviewPlayer.tsx index c96a3ee..2780829 100644 --- a/components/player/ReviewPlayer.tsx +++ b/components/player/ReviewPlayer.tsx @@ -33,6 +33,10 @@ interface ReviewPlayerProps { className?: string; onAddComment?: (frameNumber: number, timestamp: number) => void; onAnnotationSaved?: (frameNumber: number) => void; + /** Override the default server action used to persist annotations (e.g. for client-token routes) */ + saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise; + /** Override the default server action used to create the companion comment */ + addCommentFn?: (data: { versionId: string; frameNumber: number; timestamp: number; text: string }) => Promise; } export const ReviewPlayer = forwardRef( @@ -46,6 +50,8 @@ export const ReviewPlayer = forwardRef( className, onAddComment, onAnnotationSaved, + saveAnnotationFn, + addCommentFn, }, ref ) { @@ -276,6 +282,8 @@ export const ReviewPlayer = forwardRef( showAnnotations={showAnnotations} existingAnnotations={stableAnnotations} onAnnotationSaved={onAnnotationSaved} + saveAnnotationFn={saveAnnotationFn as any} + addCommentFn={addCommentFn} /> {/* JKL hint overlay — shown briefly when reversing */}