feat(client-portal): let a reviewer edit and delete their own notes
Notes left through a review link were write-once. A client who mistyped a timecode or changed their mind had no way to correct it and had to add a second note contradicting the first, which the studio then had to reconcile by hand. PATCH and DELETE on comment/[commentId] cover both. Only the reviewer's own notes are touchable: the studio's comments and those of every other review session are read-only through a link, whatever the holder sends. Ownership is re-checked server-side on every write, so the canEdit flag now returned by the versions route only decides whether to offer the controls and grants nothing. Refusals are 404 rather than 403 throughout, since a link holder has no business learning which comment ids exist outside their own notes. 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 already have actioned; a note about a different frame is a new note. Deleting a drawing note also removes its strokes, mirroring deleteComment in actions/comments.ts, and the page reloads the player afterwards so the frame does not keep showing marks that no longer exist. Delete goes through a confirmation that shows the note and says plainly that it disappears for the studio too. Worth knowing before this reaches a show: everyone holding the same review link is the same author. A ReviewSession with no email resolves to a token-derived guest user, so one link sent to three people gives all three one identity and any of them can now edit or delete the others' notes. That model is not new, which is why their comments already showed as one name, but edit and delete is the first feature where it grants real power rather than muddling attribution. Per-reviewer links with email set are the fix if that is not wanted, and that is a change to how links are issued rather than to this code. The scope is documented on reviewClientEmail so the next reader meets it before it surprises them. getOrCreateClientUser was copy-pasted into the comment and approve routes with the fallback-email expression spelled out in three places. It is one set of helpers in review-auth.ts now, including findReviewUser, a read-only variant so that merely opening a review link does not conjure a User row just to answer "is this theirs?", and ownsComment, the single rule shared by the route that offers the controls and the route that enforces them. Nine tests cover that rule directly: internal users' notes stay read-only, a session that has never posted owns nothing, and an authorless comment — one whose User was deleted, since Comment.authorId is SetNull — cannot be claimed by anyone. The routes themselves are not covered, so exercise edit and delete against a real review link before handing one to a client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [submittingComment, setSubmittingComment] = useState(false);
|
||||
|
||||
// Editing / deleting one's own notes
|
||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [deletingComment, setDeletingComment] = useState<Comment | null>(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({
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="space-y-2">
|
||||
{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.
|
||||
<div className="rounded-lg bg-zinc-800/70 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-400">Editing note</span>
|
||||
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
||||
{frameToTimecode(comment.frameNumber, version.fps)}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={editingText}
|
||||
onChange={(e) => setEditingText(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
autoFocus
|
||||
className="bg-zinc-900 border-zinc-700 text-sm resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSaveEditedComment();
|
||||
if (e.key === "Escape") cancelEditComment();
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!editingText.trim() || savingEdit}
|
||||
onClick={handleSaveEditedComment}
|
||||
>
|
||||
{savingEdit ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={cancelEditComment}
|
||||
disabled={savingEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
|
||||
className="group rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
|
||||
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
|
||||
title={`Jump to frame ${comment.frameNumber}`}
|
||||
>
|
||||
@@ -529,7 +635,36 @@ export default function ClientReviewPage({
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
||||
{comment.canEdit && (
|
||||
// Own notes only. The buttons stop propagation so they
|
||||
// do not also seek the player to the note's frame.
|
||||
<div className="flex items-center gap-1 pt-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-zinc-400 hover:text-white hover:bg-zinc-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginEditComment(comment);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-zinc-400 hover:text-red-400 hover:bg-zinc-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingComment(comment);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{comment.replies.map((reply) => (
|
||||
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
|
||||
<span className="text-xs text-zinc-400 font-medium">
|
||||
@@ -658,6 +793,49 @@ export default function ClientReviewPage({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation — a note may already have been actioned by the
|
||||
studio, so removing it is worth one deliberate step. */}
|
||||
<Dialog
|
||||
open={!!deletingComment}
|
||||
onOpenChange={(open) => { if (!open) setDeletingComment(null); }}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this note?</DialogTitle>
|
||||
</DialogHeader>
|
||||
{deletingComment && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-zinc-500 font-mono">
|
||||
Frame {deletingComment.frameNumber} ·{" "}
|
||||
{version ? frameToTimecode(deletingComment.frameNumber, version.fps) : ""}
|
||||
</p>
|
||||
<p className="rounded bg-zinc-800/70 p-3 text-sm text-zinc-200">
|
||||
{deletingComment.text}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
This removes it for everyone, including the studio. It cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingComment(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteComment}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete note"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Mock Prisma so the pure helpers can be tested without a database.
|
||||
vi.mock("@/lib/db", () => ({ db: {} }));
|
||||
|
||||
import { reviewClientEmail, ownsComment } from "./review-auth";
|
||||
import type { ReviewSession } from "@prisma/client";
|
||||
|
||||
const session = (email: string | null): ReviewSession =>
|
||||
({ email, label: null } as ReviewSession);
|
||||
|
||||
describe("reviewClientEmail", () => {
|
||||
it("uses the session's own email when it has one", () => {
|
||||
expect(reviewClientEmail(session("dailies@client.com"), "tok123456789")).toBe(
|
||||
"dailies@client.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a token-derived address when it has none", () => {
|
||||
expect(reviewClientEmail(session(null), "tok123456789")).toBe(
|
||||
"client+tok12345@review.external"
|
||||
);
|
||||
});
|
||||
|
||||
// Identity has to be stable across requests, or a reviewer would stop owning
|
||||
// the notes they wrote a minute ago.
|
||||
it("is stable for the same token", () => {
|
||||
const a = reviewClientEmail(session(null), "tok123456789");
|
||||
const b = reviewClientEmail(session(null), "tok123456789");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("separates two different review links", () => {
|
||||
expect(reviewClientEmail(session(null), "aaaaaaaaaaaa")).not.toBe(
|
||||
reviewClientEmail(session(null), "bbbbbbbbbbbb")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownsComment", () => {
|
||||
it("lets a reviewer edit their own note", () => {
|
||||
expect(ownsComment({ authorId: "u1" }, "u1")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses another author's note", () => {
|
||||
expect(ownsComment({ authorId: "u2" }, "u1")).toBe(false);
|
||||
});
|
||||
|
||||
// The studio's notes reach the portal in the same list; they must stay
|
||||
// read-only there.
|
||||
it("refuses a note written by an internal user", () => {
|
||||
expect(ownsComment({ authorId: "supervisor-id" }, "client-id")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses when the reviewer has never posted, so has no identity yet", () => {
|
||||
expect(ownsComment({ authorId: "u1" }, undefined)).toBe(false);
|
||||
expect(ownsComment({ authorId: "u1" }, null)).toBe(false);
|
||||
});
|
||||
|
||||
// An authorless comment (its User row was deleted — Comment.authorId is
|
||||
// SetNull) belongs to nobody and must not become claimable.
|
||||
it("refuses an authorless note rather than letting it be claimed", () => {
|
||||
expect(ownsComment({ authorId: null }, "u1")).toBe(false);
|
||||
expect(ownsComment({ authorId: null }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -32,3 +32,62 @@ export async function validateReviewToken(
|
||||
|
||||
return { type: "ok", session };
|
||||
}
|
||||
|
||||
/**
|
||||
* The email a review session writes its activity under.
|
||||
*
|
||||
* A session with no email on it still needs a stable identity so its comments
|
||||
* group under one author and stay attributable to that link — hence the
|
||||
* token-derived fallback. Note what this means for ownership: everyone holding
|
||||
* the same review link *is* the same author. That is the intended scope, since
|
||||
* the link is the credential.
|
||||
*/
|
||||
export function reviewClientEmail(session: ReviewSession, token: string): string {
|
||||
return session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
}
|
||||
|
||||
/** The guest User a review session posts as, creating it on first activity. */
|
||||
export async function getOrCreateReviewUser(session: ReviewSession, token: string) {
|
||||
const email = reviewClientEmail(session, token);
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: session.label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The guest User a review session posts as, or null if it has never posted.
|
||||
*
|
||||
* Read-only counterpart to getOrCreateReviewUser, for the paths that only need
|
||||
* to ask "is this theirs?" — merely opening a review link should not conjure a
|
||||
* User row.
|
||||
*/
|
||||
export async function findReviewUser(session: ReviewSession, token: string) {
|
||||
return db.user.findUnique({
|
||||
where: { email: reviewClientEmail(session, token) },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* May this review session change or remove this comment?
|
||||
*
|
||||
* Only its own. The studio's notes and those of every other review session are
|
||||
* read-only through a client link, and an authorless comment (its User was
|
||||
* deleted) belongs to nobody and so cannot be claimed.
|
||||
*
|
||||
* Shared by the listing route, which uses it to decide whether to offer the
|
||||
* controls, and the edit/delete route, which enforces it.
|
||||
*/
|
||||
export function ownsComment(
|
||||
comment: { authorId: string | null },
|
||||
reviewUserId: string | null | undefined
|
||||
): boolean {
|
||||
return !!reviewUserId && !!comment.authorId && comment.authorId === reviewUserId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user