added unapprove
Deploy / deploy (push) Successful in 2m37s

This commit is contained in:
twotalesanimation
2026-06-11 15:19:12 +02:00
parent 7b9bbbec16
commit b640fa472c
5 changed files with 361 additions and 2 deletions
+208
View File
@@ -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.
+65
View File
@@ -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 };
}
+37
View File
@@ -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"
+21 -1
View File
@@ -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 && (