From b640fa472ce75cdd1e142f065fee42c7fd969eb4 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:19:12 +0200 Subject: [PATCH] added unapprove --- Shot task workflow.md | 208 ++++++++++++++++++ actions/approvals.ts | 65 ++++++ actions/shots.ts | 37 ++++ .../projects/[id]/shots/[shotId]/page.tsx | 31 ++- components/versions/VersionList.tsx | 22 +- 5 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 Shot task workflow.md diff --git a/Shot task workflow.md b/Shot task workflow.md new file mode 100644 index 0000000..0c6226e --- /dev/null +++ b/Shot task workflow.md @@ -0,0 +1,208 @@ +# Shot / Task / Version & Approval Status Workflow + +## Core Entities & Status Fields + +| Entity | Status Field | Enum | +|---|---|---| +| **Shot** | `status` (derived) | `ShotStatus` | +| **Shot** | `shotApprovalStatus` | `ShotApprovalStatus` | +| **Task** | `status` | `TaskStatus` | +| **Version** | `approvalStatus` | `ApprovalStatus` | +| **Version** | `reviewStatus` | `ReviewStatus` (lightly used) | + +--- + +## Data Model & Enums + +| Enum | States | Purpose | +|------|--------|---------| +| **ShotStatus** | `WAITING`, `IN_PROGRESS`, `INTERNAL_REVIEW`, `READY_FOR_CLIENT`, `CLIENT_REVIEW`, `REVISIONS`, `COMPLETE` | Derived shot state (read-only, calculated) | +| **ShotApprovalStatus** | `PENDING`, `INTERNALLY_APPROVED`, `CLIENT_APPROVED` | Shot-level approval stage (manually set) | +| **TaskStatus** | `TODO`, `IN_PROGRESS`, `INTERNAL_REVIEW`, `CLIENT_REVIEW`, `CHANGES`, `DONE` | Task execution state | +| **ApprovalStatus** | `PENDING_REVIEW`, `APPROVED`, `REJECTED`, `NEEDS_CHANGES` | Version review decision | +| **ReviewStatus** | `PENDING`, `INTERNAL_APPROVED`, `CLIENT_APPROVED`, `NEEDS_CHANGES`, `FINAL_APPROVED` | Version review progression (legacy/parallel) | + +### Core Relationships + +``` +Project → Shot (1:N) + ↓ +Shot → Task (1:N) + ↓ +Task → Version (1:N) + ↓ +Version → Approval (1:N, one per reviewer) +``` + +--- + +## Shot Status is Derived, Not Set Directly + +`ShotStatus` is computed by `deriveShotStatus()` in `lib/shot-status.ts` from task states and approval fields. It is recalculated via `recalcShotStatus()` whenever a task, version, or approval changes. + +Priority rules (highest wins): + +``` +REVISIONS ← any task is CHANGES +COMPLETE ← shotApprovalStatus === CLIENT_APPROVED +CLIENT_REVIEW ← INTERNALLY_APPROVED + sharedWithClient = true +READY_FOR_CLIENT ← INTERNALLY_APPROVED + sharedWithClient = false +IN_PROGRESS ← any task is TODO or IN_PROGRESS +INTERNAL_REVIEW ← tasks exist, none blocking (approval still PENDING) +WAITING ← no tasks +``` + +`recalcShotStatus()` is automatically triggered on: +- Task creation (added to shot) +- Task status update +- Approval submission + +--- + +## Version → Approval → Task Pipeline + +``` +Artist uploads Version + → all prior versions: isLatest = false + → new Version: approvalStatus = PENDING_REVIEW, isLatest = true + → Task auto-moves → INTERNAL_REVIEW + → Shot recalculated → INTERNAL_REVIEW + → Notifications sent to supervisors/producers + ↓ +Reviewer submits Approval (creates Approval record) + → Version.approvalStatus updated + → APPROVED → Task → DONE + → REJECTED → Task → CHANGES + → NEEDS_CHANGES → Task → CHANGES + → Shot recalculated + → Notifications sent (artist, Slack webhook) +``` + +### Approval Status Mapping + +| `Version.approvalStatus` | `Task.status` | `Shot.status` (if isolated) | +|---|---|---| +| `PENDING_REVIEW` | `INTERNAL_REVIEW` | `INTERNAL_REVIEW` | +| `APPROVED` | `DONE` | depends on other tasks | +| `REJECTED` | `CHANGES` | `REVISIONS` | +| `NEEDS_CHANGES` | `CHANGES` | `REVISIONS` | + +### Role-Based Access for Approvals + +- Only ADMIN, PRODUCER, SUPERVISOR, CLIENT can approve +- Client can only approve if shot is shared (`sharedWithClient = true`) +- Approval records are **append-only** — each review creates a new `Approval` row; the latest one wins + +--- + +## Task Status Transitions + +``` +TODO + ↓ (artist starts work / uploads version) +IN_PROGRESS / INTERNAL_REVIEW + ↓ (approval submitted) + ├─→ DONE (if APPROVED) + └─→ CHANGES (if REJECTED or NEEDS_CHANGES) + ↓ +CLIENT_REVIEW (when shot is shared with client) +``` + +| Action | Trigger | Effect | +|--------|---------|--------| +| `createTask()` | Producer creates task | Creates in TODO; calls `recalcShotStatus()` | +| `updateTaskStatus()` | Manual status change | Updates task; triggers notifications + shot recalc | +| Task → `INTERNAL_REVIEW` | Version uploaded | Auto-triggered in `actions/versions.ts` | +| Task → `CLIENT_REVIEW` | Shot shared with client | Auto-triggered in `actions/shots.ts` | +| Task → `DONE` | Version approved | Auto-triggered in `actions/approvals.ts` | +| Task → `CHANGES` | Version rejected/needs changes | Auto-triggered in `actions/approvals.ts` | + +**Permissions:** ADMIN, PRODUCER, SUPERVISOR can create/update tasks. + +--- + +## Shot-Level Approval Lifecycle + +``` +PENDING (initial) + ↓ internallyApproveShot() [ADMIN / PRODUCER / SUPERVISOR] +INTERNALLY_APPROVED → Shot status: READY_FOR_CLIENT + ↓ shareWithClient() +sharedWithClient = true → Shot status: CLIENT_REVIEW + latest versions: isClientVisible = true, sharedAt = now + ↓ ↓ +clientApproveShot() clientRequestsChanges() +shotApprovalStatus = shotApprovalStatus = PENDING +CLIENT_APPROVED sharedWithClient = false +Shot status: COMPLETE isClientVisible = false (all task versions) + Shot status: REVISIONS (if tasks have CHANGES) +``` + +### State Details + +| State | `shotApprovalStatus` | `sharedWithClient` | `Shot.status` | +|---|---|---|---| +| Initial | `PENDING` | `false` | `WAITING` / `IN_PROGRESS` / `INTERNAL_REVIEW` | +| Internally approved | `INTERNALLY_APPROVED` | `false` | `READY_FOR_CLIENT` | +| Shared with client | `INTERNALLY_APPROVED` | `true` | `CLIENT_REVIEW` | +| Client approved | `CLIENT_APPROVED` | `true` | `COMPLETE` | +| Revisions requested | `PENDING` | `false` | `REVISIONS` | + +--- + +## Key Implementation Files + +| Concern | File | +|---|---| +| Status derivation engine | `lib/shot-status.ts` | +| Shot actions (share/approve/revise) | `actions/shots.ts` | +| Approval creation + cascades | `actions/approvals.ts` | +| Version upload + task auto-transition | `actions/versions.ts` | +| Task creation / status updates | `actions/tasks.ts` | +| Shot status UI badges | `components/shots/ShotCard.tsx` | +| Task status UI badges | `components/tasks/TaskCard.tsx` | + +--- + +## Notable Design Choices + +- **`ShotStatus` is never written directly** — always a computed projection of task + approval state, preventing inconsistency. +- **`ReviewStatus` on Version is largely redundant** with `approvalStatus`; it appears to be a legacy/parallel field not driving much logic currently. +- **Approval records are append-only** — each review creates a new `Approval` row; the latest one wins for updating `Version.approvalStatus`. +- **Client visibility is gated at two levels**: `Shot.sharedWithClient` and `Version.isClientVisible`, both toggled atomically when sharing. +- **`shareWithClient()` is atomic**: all task versions are updated in a single transaction. + +--- + +## Unapprove / Undo Approval + +### Version-Level Unapprove (`unapproveVersion`) + +Available to: **ADMIN, PRODUCER, SUPERVISOR** +Shown as: **"Undo Approval"** in the version dropdown (only visible when `approvalStatus === APPROVED`) +Location: `VersionList.tsx` → `actions/approvals.ts` + +``` +Version.approvalStatus === APPROVED + ↓ unapproveVersion() +Version.approvalStatus → PENDING_REVIEW +Task.status (if DONE) → INTERNAL_REVIEW +Audit: new Approval record created with status = PENDING_REVIEW, notes = "Approval undone" +Shot recalculated +``` + +### Shot-Level Unapprove (`unapproveShot`) + +Available to: **ADMIN, PRODUCER, SUPERVISOR** +Shown as: **"Undo Client Approval"** button on the shot detail page (only when `shotApprovalStatus === CLIENT_APPROVED`) +Location: `shots/[shotId]/page.tsx` → `actions/shots.ts` + +``` +shotApprovalStatus === CLIENT_APPROVED (Shot status: COMPLETE) + ↓ unapproveShot() +shotApprovalStatus → INTERNALLY_APPROVED +Shot.status → CLIENT_REVIEW (if still sharedWithClient) + → READY_FOR_CLIENT (if sharedWithClient = false) +``` + +Neither unapprove action modifies the `sharedWithClient` flag or version visibility — only the approval state is rolled back. diff --git a/actions/approvals.ts b/actions/approvals.ts index f44653b..3cdd382 100644 --- a/actions/approvals.ts +++ b/actions/approvals.ts @@ -128,3 +128,68 @@ export async function getApprovalHistory(versionId: string) { orderBy: { createdAt: "desc" }, }); } + +/** + * Resets an approved version back to PENDING_REVIEW and the associated task + * back to INTERNAL_REVIEW. Recalculates shot status. + * Only ADMIN, PRODUCER, SUPERVISOR can unapprove. + */ +export async function unapproveVersion(versionId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + + const allowedRoles = ["ADMIN", "PRODUCER", "SUPERVISOR"]; + if (!allowedRoles.includes(session.user.role)) { + throw new Error("Insufficient permissions to unapprove versions"); + } + + const version = await db.version.findUnique({ + where: { id: versionId }, + include: { + task: { + include: { shot: true }, + }, + }, + }); + if (!version) throw new Error("Version not found"); + if (version.approvalStatus !== "APPROVED") { + throw new Error("Only approved versions can be unapproved"); + } + + // Create an audit record for the unapproval + await db.approval.create({ + data: { + versionId, + userId: session.user.id, + status: "PENDING_REVIEW", + notes: "Approval undone", + }, + }); + + // Reset version approval status + await db.version.update({ + where: { id: versionId }, + data: { approvalStatus: "PENDING_REVIEW" }, + }); + + // Reset task back to INTERNAL_REVIEW if it was moved to DONE + if (version.task && version.task.status === "DONE") { + await db.task.update({ + where: { id: version.task.id }, + data: { status: "INTERNAL_REVIEW" }, + }); + } + + // Recalculate shot status + if (version.task?.shot) { + await recalcShotStatus(version.task.shot.id).catch(() => {}); + } + + revalidatePath(`/review/${versionId}`); + if (version.task) { + revalidatePath(`/tasks/${version.task.id}`); + revalidatePath(`/projects/${version.task.projectId}`); + } + + return { success: true }; +} diff --git a/actions/shots.ts b/actions/shots.ts index 5233667..a317e13 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -744,6 +744,43 @@ export async function clientApproveShot(shotId: string) { return { success: true }; } +/** + * Admin/Producer/Supervisor: undo a client approval on a shot. + * Resets shotApprovalStatus from CLIENT_APPROVED → INTERNALLY_APPROVED. + * Shot status reverts to CLIENT_REVIEW (if still shared) or READY_FOR_CLIENT. + */ +export async function unapproveShot(shotId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true, shotApprovalStatus: true, sharedWithClient: true }, + }); + if (!shot) throw new Error("Shot not found"); + if (shot.shotApprovalStatus !== "CLIENT_APPROVED") { + throw new Error("Shot is not client-approved"); + } + + const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT"; + + await db.shot.update({ + where: { id: shotId }, + data: { + shotApprovalStatus: "INTERNALLY_APPROVED", + status: newStatus, + }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + /** * Internal: handle client requesting changes on a shot. * Resets shotApprovalStatus = PENDING, sharedWithClient = false. diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 0cef147..213a04b 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -29,7 +29,7 @@ import { import type { ShotWithDetails } from "@/types"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { FootageViewer } from "@/components/shots/FootageViewer"; -import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient } from "@/actions/shots"; +import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots"; const STATUS_CONFIG: Record< string, @@ -137,6 +137,21 @@ export default function ShotDetailPage() { } }; + const handleUnapproveShot = async () => { + if (!shot) return; + if (!confirm("Undo client approval? The shot will return to Internally Approved.")) return; + setIsActioning(true); + try { + await unapproveShot(shot.id); + toast({ title: "Client approval undone", description: "Status: Ready for Client" }); + fetchShot(); + } catch (e) { + toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setIsActioning(false); + } + }; + const handleDuplicate = async () => { if (!shot) return; setIsDuplicating(true); @@ -317,6 +332,20 @@ export default function ShotDetailPage() { )} + {/* Undo client approval */} + {canInternallyApprove && shot.shotApprovalStatus === "CLIENT_APPROVED" && ( + + )} +