28e8c47907
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>
178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
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, getOrCreateReviewUser } from "@/lib/review-auth";
|
|
|
|
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, shotId, action, status, notes } = body;
|
|
|
|
// ── Shot-level approval action ──────────────────────────────────────────────
|
|
if (shotId && action) {
|
|
const shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true, sharedWithClient: true, shotVersion: true },
|
|
});
|
|
if (!shot || shot.projectId !== session.projectId) {
|
|
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
|
}
|
|
if (!shot.sharedWithClient) {
|
|
return NextResponse.json({ error: "Shot is not available for client review" }, { status: 403 });
|
|
}
|
|
|
|
if (action === "APPROVE") {
|
|
await db.$transaction(async (tx) => {
|
|
await tx.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
|
});
|
|
await recalcShotStatus(shotId, tx);
|
|
});
|
|
} else if (action === "NEEDS_CHANGES") {
|
|
// Increment shotVersion: v001 → v002, v009 → v010, etc.
|
|
const currentVer = shot.shotVersion ?? "v001";
|
|
const verMatch = currentVer.match(/^v(\d+)$/);
|
|
const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2;
|
|
const nextVersion = "v" + String(nextNum).padStart(3, "0");
|
|
|
|
const user = await getOrCreateReviewUser(session, token);
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotVersion: nextVersion },
|
|
});
|
|
|
|
// Clears the approval, unshares, marks the tasks needing rework and
|
|
// recalculates — including the case where every task was already DONE,
|
|
// which used to match nothing and leave the request with no effect.
|
|
await markShotForRevisions(tx, shotId);
|
|
|
|
// The shot-level verdict has to land on the versions too: the dashboard
|
|
// counters, the version badges and the client portal's own filters all
|
|
// read Version.approvalStatus, so a shot-level change request was
|
|
// invisible everywhere those look.
|
|
const shown = await tx.version.findMany({
|
|
where: { task: { shotId }, isClientVisible: true, isLatest: true },
|
|
select: { id: true },
|
|
});
|
|
for (const v of shown) {
|
|
await tx.approval.create({
|
|
data: {
|
|
versionId: v.id,
|
|
userId: user.id,
|
|
status: "NEEDS_CHANGES",
|
|
notes: notes ?? "Changes requested on the shot",
|
|
},
|
|
});
|
|
await tx.version.update({
|
|
where: { id: v.id },
|
|
data: { approvalStatus: "NEEDS_CHANGES" },
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
|
}
|
|
|
|
return NextResponse.json({ success: true });
|
|
}
|
|
|
|
// ── Version-level approval (legacy task-based flow) ─────────────────────────
|
|
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
|
|
if (!versionId || !validStatuses.includes(status)) {
|
|
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
|
|
}
|
|
|
|
// Ensure the version belongs to this project via its task
|
|
const version = await db.version.findUnique({
|
|
where: { id: versionId },
|
|
include: {
|
|
task: {
|
|
include: { shot: true, project: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
const projectId = version?.task?.projectId;
|
|
if (!version || projectId !== session.projectId) {
|
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
|
}
|
|
|
|
const user = await getOrCreateReviewUser(session, token);
|
|
|
|
// Record approval
|
|
await db.approval.create({
|
|
data: { versionId, userId: user.id, status, notes },
|
|
});
|
|
|
|
// Update version approval status
|
|
await db.version.update({
|
|
where: { id: versionId },
|
|
data: { approvalStatus: status },
|
|
});
|
|
|
|
// Update task status based on approval decision
|
|
if (version.task) {
|
|
if (status === "APPROVED") {
|
|
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
|
|
|
|
// If this version belongs to a shot that is shared with the client,
|
|
// also approve the shot at the shot level so it moves to COMPLETE.
|
|
if (version.task.shot) {
|
|
const shot = await db.shot.findUnique({
|
|
where: { id: version.task.shot.id },
|
|
select: { sharedWithClient: true, shotApprovalStatus: true },
|
|
});
|
|
if (shot?.sharedWithClient && shot.shotApprovalStatus !== "CLIENT_APPROVED") {
|
|
// status is left to the recalc below — writing COMPLETE here was
|
|
// overwritten by it anyway, and disagreed with it whenever another
|
|
// task on the shot was still on CHANGES.
|
|
await db.shot.update({
|
|
where: { id: version.task.shot.id },
|
|
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
|
|
}
|
|
|
|
// Recalculate derived shot status
|
|
if (version.task.shot) {
|
|
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Slack notification
|
|
if (version.task?.project?.slackWebhook) {
|
|
const { slackNotifyApproval } = await import("@/lib/slack");
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
|
const contextCode = version.task.shot?.shotCode ?? version.task.title;
|
|
await slackNotifyApproval(version.task.project.slackWebhook, {
|
|
shotCode: contextCode,
|
|
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
|
|
reviewerName: user.name ?? "Client",
|
|
status,
|
|
projectName: version.task.project.name,
|
|
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({ success: true });
|
|
}
|