import { ShotStatus, ShotApprovalStatus, TaskStatus } from "@prisma/client"; import { db } from "@/lib/db"; import type { PrismaClient } from "@prisma/client"; type TxClient = Omit< PrismaClient, "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends" >; /** * Derive shot status from tasks + shot-level approval fields. * * Priority order (highest → lowest): * 1. REVISIONS — any task is CHANGES * 2. COMPLETE — shotApprovalStatus === CLIENT_APPROVED * 3. CLIENT_REVIEW — shotApprovalStatus === INTERNALLY_APPROVED && sharedWithClient * 4. READY_FOR_CLIENT — shotApprovalStatus === INTERNALLY_APPROVED && !sharedWithClient * 5. IN_PROGRESS — any task is TODO or IN_PROGRESS * 6. INTERNAL_REVIEW — tasks exist but none blocking, approval still PENDING * 7. WAITING — no tasks */ export function deriveShotStatus( tasks: { status: TaskStatus }[], shotApprovalStatus: ShotApprovalStatus, sharedWithClient: boolean ): ShotStatus { // 1. Changes requested — highest priority if (tasks.some((t) => t.status === "CHANGES")) return "REVISIONS"; // 2. Client approved — terminal completion state if (shotApprovalStatus === "CLIENT_APPROVED") return "COMPLETE"; // 3 & 4. Internally approved — client-facing states if (shotApprovalStatus === "INTERNALLY_APPROVED") { return sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT"; } // 5. Active work in progress (approval still PENDING) if (tasks.some((t) => t.status === "TODO" || t.status === "IN_PROGRESS")) { return "IN_PROGRESS"; } // 6. All work done, awaiting internal approval if (tasks.length > 0) return "INTERNAL_REVIEW"; // 7. No tasks yet return "WAITING"; } /** * Query all tasks + approval fields for a shot, derive the correct ShotStatus, * and persist it. Accepts an optional Prisma transaction client (tx). */ export async function recalcShotStatus( shotId: string, tx?: TxClient ): Promise { const client = tx ?? db; const [tasks, shot] = await Promise.all([ client.task.findMany({ where: { shotId }, select: { status: true }, }), client.shot.findUnique({ where: { id: shotId }, select: { shotApprovalStatus: true, sharedWithClient: true }, }), ]); if (!shot) return; const newStatus = deriveShotStatus(tasks, shot.shotApprovalStatus, shot.sharedWithClient); await client.shot.update({ where: { id: shotId }, data: { status: newStatus }, }); } /** * New review material arrived on a shot, so any standing sign-off is stale. * * deriveShotStatus short-circuits on shotApprovalStatus: a CLIENT_APPROVED shot * reads COMPLETE and an INTERNALLY_APPROVED one reads READY_FOR_CLIENT no matter * what its tasks say. Without this, uploading a new version onto an approved * shot moved the task to INTERNAL_REVIEW and changed nothing visible — the shot * still read as signed off while carrying work nobody had looked at. * * sharedWithClient is left alone; clearing the approval is enough to pull the * shot back to INTERNAL_REVIEW, and revoking client access is a separate, * deliberate act (unshareFromClient). * * @returns true if a stale approval was actually cleared. */ export async function clearStaleShotApproval( client: TxClient, shotId: string ): Promise { const shot = await client.shot.findUnique({ where: { id: shotId }, select: { shotApprovalStatus: true }, }); if (!shot || shot.shotApprovalStatus === "PENDING") return false; await client.shot.update({ where: { id: shotId }, data: { shotApprovalStatus: "PENDING" }, }); return true; } /** * Put a shot into revisions: clear its approval, unshare it, and mark the tasks * that need rework so the derivation lands on REVISIONS. * * The task selection is the subtle part. Marking every non-DONE task as CHANGES * matches nothing at all on the shot this is most often called for — one that is * out for client review precisely *because* all of its tasks are finished. That * left the shot deriving to INTERNAL_REVIEW and the change request with no * visible effect anywhere. So when nothing was picked up, the task behind the * shot's latest version is marked instead: that is the deliverable the reviewer * was actually looking at. * * @param taskId Mark only this task (a reviewer calling out one specific task). * @returns ids of the tasks moved to CHANGES. */ export async function markShotForRevisions( client: TxClient, shotId: string, taskId?: string ): Promise { await client.shot.update({ where: { id: shotId }, data: { shotApprovalStatus: "PENDING", sharedWithClient: false }, }); let changed: string[]; if (taskId) { await client.task.update({ where: { id: taskId }, data: { status: "CHANGES" } }); changed = [taskId]; } else { const open = await client.task.findMany({ where: { shotId, status: { not: "DONE" } }, select: { id: true }, }); if (open.length > 0) { await client.task.updateMany({ where: { shotId, status: { not: "DONE" } }, data: { status: "CHANGES" }, }); changed = open.map((t) => t.id); } else { // Every task is DONE — reopen the one that produced what was reviewed. const latest = await client.version.findFirst({ where: { task: { shotId } }, orderBy: [{ isLatest: "desc" }, { createdAt: "desc" }], select: { taskId: true }, }); if (latest?.taskId) { await client.task.update({ where: { id: latest.taskId }, data: { status: "CHANGES" }, }); changed = [latest.taskId]; } else { changed = []; } } } await recalcShotStatus(shotId, client); return changed; }