842a8fe036
Deploy / deploy (push) Successful in 3m34s
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.
A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.
Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
254 lines
8.6 KiB
TypeScript
254 lines
8.6 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
|
|
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
|
|
import { updateTaskStatus } from "@/actions/tasks";
|
|
|
|
/** Comment.source for notes pasted from a client message */
|
|
const SOURCE = "WHATSAPP";
|
|
|
|
async function requireStaff() {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
|
|
return session.user;
|
|
}
|
|
|
|
/**
|
|
* The shot a pasted code means: exact code or client alias first, then a code
|
|
* the client shortened by dropping the show prefix ("113_018_040" for
|
|
* "UNG_113_018_040") when exactly one shot ends with it.
|
|
*/
|
|
function resolveShot<T extends BoardShot>(code: string, shots: T[]): T | null {
|
|
const exact = findShot(code, shots) as T | null;
|
|
if (exact) return exact;
|
|
const tail = `_${code.toUpperCase()}`;
|
|
const hits = shots.filter((s) => s.shotCode.toUpperCase().endsWith(tail));
|
|
return hits.length === 1 ? hits[0] : null;
|
|
}
|
|
|
|
export interface FeedbackVersionOption {
|
|
id: string;
|
|
label: string;
|
|
sent: boolean;
|
|
sharedAt: string | null;
|
|
taskId: string | null;
|
|
taskStatus: string | null;
|
|
/** The note's quoted frame converted for this version's media; null = no frame quoted */
|
|
playerFrame: number | null;
|
|
fromComp: boolean;
|
|
}
|
|
|
|
export interface FeedbackPreviewNote {
|
|
key: string;
|
|
code: string;
|
|
text: string;
|
|
quotedFrame: number | null;
|
|
shot: { id: string; shotCode: string } | null;
|
|
suggestion: string | null;
|
|
versions: FeedbackVersionOption[];
|
|
defaultVersionId: string | null;
|
|
}
|
|
|
|
export interface FeedbackPreview {
|
|
rounds: { title: string; dueText: string | null; notes: FeedbackPreviewNote[] }[];
|
|
ignored: string[];
|
|
}
|
|
|
|
export async function previewFeedback(projectId: string, input: string): Promise<FeedbackPreview> {
|
|
await requireStaff();
|
|
z.string().cuid().parse(projectId);
|
|
const parsed = parseFeedback(z.string().max(50000).parse(input));
|
|
|
|
const shots = await db.shot.findMany({
|
|
where: { projectId },
|
|
select: { id: true, shotCode: true, clientCode: true, frameStart: true },
|
|
});
|
|
|
|
const matched = new Map<string, (typeof shots)[number] | null>();
|
|
for (const r of parsed.rounds) for (const n of r.notes) {
|
|
const code = n.codes[0];
|
|
if (!matched.has(code)) matched.set(code, resolveShot(code, shots));
|
|
}
|
|
const shotIds = [...new Set([...matched.values()].filter(Boolean).map((s) => s!.id))];
|
|
|
|
// Recent versions per shot, whether hung off the shot or its task
|
|
const versions = shotIds.length
|
|
? await db.version.findMany({
|
|
where: { OR: [{ shotId: { in: shotIds } }, { task: { shotId: { in: shotIds } } }] },
|
|
orderBy: { createdAt: "desc" },
|
|
select: {
|
|
id: true,
|
|
versionNumber: true,
|
|
shotId: true,
|
|
isClientVisible: true,
|
|
sharedAt: true,
|
|
frameCount: true,
|
|
pictureStartFrame: true,
|
|
task: { select: { id: true, status: true, shotId: true } },
|
|
},
|
|
})
|
|
: [];
|
|
const byShot = new Map<string, typeof versions>();
|
|
for (const v of versions) {
|
|
const sid = v.shotId ?? v.task?.shotId;
|
|
if (!sid) continue;
|
|
const list = byShot.get(sid) ?? [];
|
|
if (list.length < 6) list.push(v);
|
|
byShot.set(sid, list);
|
|
}
|
|
|
|
let k = 0;
|
|
return {
|
|
ignored: parsed.ignored,
|
|
rounds: parsed.rounds.map((r) => ({
|
|
title: r.title,
|
|
dueText: r.dueText,
|
|
notes: r.notes.map((n) => {
|
|
const code = n.codes[0];
|
|
const shot = matched.get(code) ?? null;
|
|
const options: FeedbackVersionOption[] = (shot ? byShot.get(shot.id) ?? [] : []).map((v) => {
|
|
const conv = n.frame != null
|
|
? toPlayerFrame(n.frame, {
|
|
shotFrameStart: shot!.frameStart,
|
|
pictureStart: slateFrames(v),
|
|
frameCount: v.frameCount,
|
|
})
|
|
: null;
|
|
return {
|
|
id: v.id,
|
|
label: `v${String(v.versionNumber).padStart(3, "0")}`,
|
|
sent: v.isClientVisible,
|
|
sharedAt: v.sharedAt?.toISOString() ?? null,
|
|
taskId: v.task?.id ?? null,
|
|
taskStatus: v.task?.status ?? null,
|
|
playerFrame: conv?.frame ?? null,
|
|
fromComp: conv?.fromComp ?? false,
|
|
};
|
|
});
|
|
// The client is commenting on what they were sent
|
|
const def = options.find((o) => o.sent) ?? options[0] ?? null;
|
|
return {
|
|
key: `n${k++}`,
|
|
code: shot?.shotCode ?? code,
|
|
text: n.text,
|
|
quotedFrame: n.frame,
|
|
shot: shot ? { id: shot.id, shotCode: shot.shotCode } : null,
|
|
suggestion: shot ? null : suggestShot(code, shots)?.shotCode ?? null,
|
|
versions: options,
|
|
defaultVersionId: def?.id ?? null,
|
|
};
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
|
|
const commitSchema = z.object({
|
|
projectId: z.string().cuid(),
|
|
rounds: z.array(z.object({
|
|
title: z.string().max(200),
|
|
dueAt: z.string().datetime().nullable(),
|
|
notes: z.array(z.object({
|
|
code: z.string().min(1).max(100),
|
|
shotId: z.string().cuid().nullable(),
|
|
text: z.string().min(1).max(5000),
|
|
versionId: z.string().cuid().nullable(),
|
|
frame: z.number().int().min(0).nullable(),
|
|
setChanges: z.boolean(),
|
|
})).min(1),
|
|
})).min(1),
|
|
});
|
|
|
|
/**
|
|
* Saves pasted client feedback: one FeedbackRound per round, each note as a
|
|
* player comment (tagged as client feedback) and a board line, and — where
|
|
* ticked — the version's task moved to Changes.
|
|
*/
|
|
export async function commitFeedback(data: z.infer<typeof commitSchema>) {
|
|
const user = await requireStaff();
|
|
const parsed = commitSchema.parse(data);
|
|
|
|
const notes = parsed.rounds.flatMap((r) => r.notes);
|
|
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
|
|
const versions = await db.version.findMany({
|
|
where: { id: { in: versionIds } },
|
|
select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true } }, shot: { select: { projectId: true } } },
|
|
});
|
|
const vById = new Map(versions.map((v) => [v.id, v]));
|
|
for (const n of notes) {
|
|
if (!n.versionId) continue;
|
|
const v = vById.get(n.versionId);
|
|
const projectOk = v && (v.shot?.projectId ?? v.task?.projectId) === parsed.projectId;
|
|
const shotOk = v && (v.shotId ?? v.task?.shotId) === n.shotId;
|
|
if (!projectOk || !shotOk) throw new Error(`Version does not belong to ${n.code}`);
|
|
}
|
|
|
|
const last = await db.boardItem.findFirst({
|
|
where: { projectId: parsed.projectId },
|
|
orderBy: { sortOrder: "desc" },
|
|
select: { sortOrder: true },
|
|
});
|
|
let sort = (last?.sortOrder ?? -1) + 1;
|
|
|
|
let comments = 0;
|
|
await db.$transaction(async (tx) => {
|
|
for (const r of parsed.rounds) {
|
|
const round = await tx.feedbackRound.create({
|
|
data: {
|
|
projectId: parsed.projectId,
|
|
title: r.title.trim() || "Client feedback",
|
|
dueAt: r.dueAt ? new Date(r.dueAt) : null,
|
|
createdById: user.id,
|
|
},
|
|
});
|
|
for (const n of r.notes) {
|
|
const v = n.versionId ? vById.get(n.versionId)! : null;
|
|
const frame = n.frame ?? 0;
|
|
const comment = v
|
|
? await tx.comment.create({
|
|
data: {
|
|
versionId: v.id,
|
|
authorId: user.id,
|
|
frameNumber: frame,
|
|
timestamp: frame / v.fps,
|
|
text: n.text.trim(),
|
|
source: SOURCE,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
: null;
|
|
if (comment) comments++;
|
|
await tx.boardItem.create({
|
|
data: {
|
|
projectId: parsed.projectId,
|
|
shotId: n.shotId,
|
|
code: n.code.toUpperCase(),
|
|
text: n.text.trim(),
|
|
roundId: round.id,
|
|
commentId: comment?.id ?? null,
|
|
sortOrder: sort++,
|
|
createdById: user.id,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Outside the transaction: updateTaskStatus notifies the artist and
|
|
// recalculates shot status, exactly as moving the task by hand does.
|
|
const tasks = new Set<string>();
|
|
for (const n of notes) {
|
|
const task = n.versionId ? vById.get(n.versionId)?.task : null;
|
|
if (n.setChanges && task && task.status !== "CHANGES") tasks.add(task.id);
|
|
}
|
|
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES");
|
|
|
|
for (const id of versionIds) revalidatePath(`/review/${id}`);
|
|
revalidatePath("/board");
|
|
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
|
|
}
|