diff --git a/app/api/client/[token]/approve/route.ts b/app/api/client/[token]/approve/route.ts index 82deee6..7fe943c 100644 --- a/app/api/client/[token]/approve/route.ts +++ b/app/api/client/[token]/approve/route.ts @@ -2,20 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { ApprovalStatus } from "@prisma/client"; import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status"; -import { validateReviewToken } from "@/lib/review-auth"; - -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, - }, - }); -} +import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth"; export async function POST( req: NextRequest, @@ -62,8 +49,7 @@ export async function POST( const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2; const nextVersion = "v" + String(nextNum).padStart(3, "0"); - const email = session.email ?? `client+${token.slice(0, 8)}@review.external`; - const user = await getOrCreateClientUser(email, session.label); + const user = await getOrCreateReviewUser(session, token); await db.$transaction(async (tx) => { await tx.shot.update({ @@ -127,8 +113,7 @@ export async function POST( return NextResponse.json({ error: "Version not found" }, { status: 404 }); } - const email = session.email ?? `client+${token.slice(0, 8)}@review.external`; - const user = await getOrCreateClientUser(email, session.label); + const user = await getOrCreateReviewUser(session, token); // Record approval await db.approval.create({ diff --git a/app/api/client/[token]/comment/[commentId]/route.ts b/app/api/client/[token]/comment/[commentId]/route.ts new file mode 100644 index 0000000..a26def6 --- /dev/null +++ b/app/api/client/[token]/comment/[commentId]/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth"; + +/** + * Edit and delete for notes left through a client review link. + * + * A reviewer may only touch their own notes: the studio's comments and those of + * any other review session are read-only here, whatever the link holder sends. + * Ownership is the review session's guest User (lib/review-auth.ts) — the link + * is the credential, so everyone holding it shares that identity. + */ + +/** Matches the internal addComment/editComment limits (actions/comments.ts). */ +const MAX_LENGTH = 5000; + +/** The annotation companion comments written by the drawing tools. */ +const ANNOTATION_PREFIX = "✏️ Annotation at frame"; + +type Ctx = { params: Promise<{ token: string; commentId: string }> }; + +/** + * Resolves the request to a comment the caller is allowed to change, or the + * response explaining why not. + * + * "Not yours" is deliberately answered with 404 rather than 403: a link holder + * has no business learning which comment ids exist outside their own notes. + */ +async function authorizeCommentEdit(req: NextRequest, ctx: Ctx) { + const { token, commentId } = await ctx.params; + + const result = await validateReviewToken(token, req); + if (result.type === "requiresPassword") { + return { error: NextResponse.json({ requiresPassword: true }, { status: 401 }) }; + } + if (result.type === "invalid") { + return { + error: NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }), + }; + } + const session = result.session; + + const notFound = NextResponse.json({ error: "Comment not found" }, { status: 404 }); + + const comment = await db.comment.findUnique({ + where: { id: commentId }, + select: { + id: true, + authorId: true, + versionId: true, + frameNumber: true, + text: true, + version: { + select: { + shot: { select: { projectId: true } }, + task: { select: { projectId: true } }, + }, + }, + }, + }); + if (!comment) return { error: notFound }; + + // Same project scoping the rest of the portal applies. + const projectId = comment.version.shot?.projectId ?? comment.version.task?.projectId; + if (projectId !== session.projectId) return { error: notFound }; + + const user = await findReviewUser(session, token); + if (!ownsComment(comment, user?.id)) return { error: notFound }; + + return { comment }; +} + +/** PATCH — change the text of one's own note. */ +export async function PATCH(req: NextRequest, ctx: Ctx) { + const authorized = await authorizeCommentEdit(req, ctx); + if (authorized.error) return authorized.error; + const { comment } = authorized; + + const body = await req.json().catch(() => null); + const text = typeof body?.text === "string" ? body.text.trim() : ""; + if (!text) { + return NextResponse.json({ error: "Note text is required" }, { status: 400 }); + } + if (text.length > MAX_LENGTH) { + return NextResponse.json( + { error: `Note must be ${MAX_LENGTH} characters or fewer` }, + { status: 400 } + ); + } + + // Only the text is editable. The frame a note is pinned to is what the studio + // works from, so moving it would silently repoint feedback someone may have + // already actioned; a note about a different frame is a new note. + const updated = await db.comment.update({ + where: { id: comment.id }, + data: { text }, + include: { + author: { select: { id: true, name: true, image: true, email: true } }, + replies: { + orderBy: { createdAt: "asc" }, + include: { author: { select: { id: true, name: true, image: true, email: true } } }, + }, + }, + }); + + return NextResponse.json({ comment: { ...updated, canEdit: true } }); +} + +/** DELETE — remove one's own note. */ +export async function DELETE(req: NextRequest, ctx: Ctx) { + const authorized = await authorizeCommentEdit(req, ctx); + if (authorized.error) return authorized.error; + const { comment } = authorized; + + await db.comment.delete({ where: { id: comment.id } }); + + // A drawing leaves a companion comment; deleting the note without the strokes + // would leave marks on the frame that nothing explains. Mirrors deleteComment + // in actions/comments.ts. + if (comment.text.startsWith(ANNOTATION_PREFIX)) { + await db.annotation.deleteMany({ + where: { versionId: comment.versionId, frameNumber: comment.frameNumber }, + }); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/client/[token]/comment/route.ts b/app/api/client/[token]/comment/route.ts index ccbe620..b007d9b 100644 --- a/app/api/client/[token]/comment/route.ts +++ b/app/api/client/[token]/comment/route.ts @@ -1,21 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { slackNotifyNewFeedback } from "@/lib/slack"; -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, - }, - }); -} +import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth"; export async function POST( req: NextRequest, @@ -52,8 +38,7 @@ export async function POST( } // Resolve commenter identity - const email = session.email ?? `client+${token.slice(0, 8)}@review.external`; - const user = await getOrCreateClientUser(email, session.label); + const user = await getOrCreateReviewUser(session, token); const comment = await db.comment.create({ data: { @@ -85,5 +70,6 @@ export async function POST( }); } - return NextResponse.json({ comment }, { status: 201 }); + // canEdit mirrors the GET shape — a note you just wrote is always your own. + return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 }); } diff --git a/app/api/client/[token]/versions/[versionId]/route.ts b/app/api/client/[token]/versions/[versionId]/route.ts index dfa06ab..bf72b58 100644 --- a/app/api/client/[token]/versions/[versionId]/route.ts +++ b/app/api/client/[token]/versions/[versionId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; -import { validateReviewToken } from "@/lib/review-auth"; +import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth"; import { clientShotCode, clientTaskTitle } from "@/lib/shot-code"; /** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */ @@ -65,7 +65,7 @@ export async function GET( return NextResponse.json({ error: "Version not found" }, { status: 404 }); } - const comments = await db.comment.findMany({ + const commentRows = await db.comment.findMany({ where: { versionId }, orderBy: { frameNumber: "asc" }, include: { @@ -79,6 +79,16 @@ export async function GET( }, }); + // Which notes this reviewer may edit or delete. Read-only: opening a review + // link should not create the guest User, so a session that has never posted + // simply owns nothing. The edit/delete route re-checks this — the flag drives + // the UI, it does not grant anything. + const reviewUser = await findReviewUser(session, token); + const comments = commentRows.map((c) => ({ + ...c, + canEdit: ownsComment(c, reviewUser?.id), + })); + // Resolve the shot that has the high-res file (comes from version.shot OR version.task.shot) const highResShot = version.shot ?? (version.task as any)?.shot ?? null; diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx index 7a4b426..609b3df 100644 --- a/app/client/[token]/review/[versionId]/page.tsx +++ b/app/client/[token]/review/[versionId]/page.tsx @@ -31,6 +31,8 @@ import { Copy, Check, Download, + Pencil, + Trash2, } from "lucide-react"; import { useReviewStore } from "@/hooks/use-review-player"; import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate"; @@ -42,6 +44,8 @@ interface Comment { text: string; isResolved: boolean; createdAt: string; + /** This reviewer wrote it, so they can change or remove it. Set by the API. */ + canEdit?: boolean; author: { id: string; name: string | null; image: string | null; email: string }; replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[]; } @@ -104,6 +108,13 @@ export default function ClientReviewPage({ const [commentFrame, setCommentFrame] = useState(null); const [submittingComment, setSubmittingComment] = useState(false); + // Editing / deleting one's own notes + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingText, setEditingText] = useState(""); + const [savingEdit, setSavingEdit] = useState(false); + const [deletingComment, setDeletingComment] = useState(null); + const [deleting, setDeleting] = useState(false); + // Approval dialog const [approvalDialog, setApprovalDialog] = useState<{ open: boolean; @@ -209,6 +220,58 @@ export default function ClientReviewPage({ } }; + const beginEditComment = (comment: Comment) => { + setEditingCommentId(comment.id); + setEditingText(comment.text); + }; + + const cancelEditComment = () => { + setEditingCommentId(null); + setEditingText(""); + }; + + const handleSaveEditedComment = async () => { + const trimmed = editingText.trim(); + if (!editingCommentId || !trimmed || !version) return; + setSavingEdit(true); + try { + const res = await fetch(`/api/client/${token}/comment/${editingCommentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: trimmed }), + }); + if (!res.ok) throw new Error("Failed to save note"); + cancelEditComment(); + await refreshComments(token, version.id); + toast({ title: "Note updated" }); + } catch { + toast({ title: "Failed to update note", variant: "destructive" }); + } finally { + setSavingEdit(false); + } + }; + + const handleDeleteComment = async () => { + if (!deletingComment || !version) return; + setDeleting(true); + try { + const res = await fetch(`/api/client/${token}/comment/${deletingComment.id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete note"); + setDeletingComment(null); + // Deleting a drawing note also removes its strokes server-side, and those + // live in the canvas's own state — a full reload remounts the player so + // the frame does not keep showing marks that no longer exist. + loadVersion(token, version.id); + toast({ title: "Note deleted" }); + } catch { + toast({ title: "Failed to delete note", variant: "destructive" }); + } finally { + setDeleting(false); + } + }; + const handleDownloadHighRes = async () => { const shotId = version?.highResShotId ?? version?.shot?.id; if (!shotId || !token) return; @@ -510,8 +573,51 @@ export default function ClientReviewPage({
{comments.map((comment) => (
+ {editingCommentId === comment.id ? ( + // Editing replaces the note in place, so it stays in the + // list at its own frame rather than jumping to a dialog. +
+
+ Editing note + + {frameToTimecode(comment.frameNumber, version.fps)} + +
+