@@ -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.
|
||||||
@@ -128,3 +128,68 @@ export async function getApprovalHistory(versionId: string) {
|
|||||||
orderBy: { createdAt: "desc" },
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -744,6 +744,43 @@ export async function clientApproveShot(shotId: string) {
|
|||||||
return { success: true };
|
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.
|
* Internal: handle client requesting changes on a shot.
|
||||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import type { ShotWithDetails } from "@/types";
|
import type { ShotWithDetails } from "@/types";
|
||||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
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<
|
const STATUS_CONFIG: Record<
|
||||||
string,
|
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 () => {
|
const handleDuplicate = async () => {
|
||||||
if (!shot) return;
|
if (!shot) return;
|
||||||
setIsDuplicating(true);
|
setIsDuplicating(true);
|
||||||
@@ -317,6 +332,20 @@ export default function ShotDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Undo client approval */}
|
||||||
|
{canInternallyApprove && shot.shotApprovalStatus === "CLIENT_APPROVED" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUnapproveShot}
|
||||||
|
disabled={isActioning}
|
||||||
|
className="gap-2 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
Undo Client Approval
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { VersionWithDetails } from "@/types";
|
import type { VersionWithDetails } from "@/types";
|
||||||
import { submitApproval } from "@/actions/approvals";
|
import { submitApproval, unapproveVersion } from "@/actions/approvals";
|
||||||
import { deleteVersion } from "@/actions/versions";
|
import { deleteVersion } from "@/actions/versions";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
@@ -113,6 +113,17 @@ export function VersionList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnapprove = async (versionId: string) => {
|
||||||
|
if (!confirm("Undo approval? The version will return to Pending Review and the task will return to Internal Review.")) return;
|
||||||
|
try {
|
||||||
|
await unapproveVersion(versionId);
|
||||||
|
toast({ title: "Approval undone", description: "Version reset to Pending Review" });
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Failed to unapprove", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (versionId: string, label: string) => {
|
const handleDelete = async (versionId: string, label: string) => {
|
||||||
if (!confirm(`Delete ${label}? This will permanently remove the file and all comments.`)) return;
|
if (!confirm(`Delete ${label}? This will permanently remove the file and all comments.`)) return;
|
||||||
try {
|
try {
|
||||||
@@ -233,6 +244,15 @@ export function VersionList({
|
|||||||
<XCircle className="h-3.5 w-3.5 mr-2" />
|
<XCircle className="h-3.5 w-3.5 mr-2" />
|
||||||
Reject
|
Reject
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{version.approvalStatus === "APPROVED" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-amber-500"
|
||||||
|
onClick={() => handleUnapprove(version.id)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Undo Approval
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|||||||
Reference in New Issue
Block a user