// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ───────────────────────────────────────────── // ENUMS // ───────────────────────────────────────────── enum Role { ADMIN PRODUCER SUPERVISOR ARTIST CLIENT } enum ProjectStatus { ACTIVE ON_HOLD COMPLETED ARCHIVED } enum ShotStatus { WAITING IN_PROGRESS INTERNAL_REVIEW READY_FOR_CLIENT CLIENT_REVIEW REVISIONS COMPLETE } enum ShotApprovalStatus { PENDING INTERNALLY_APPROVED CLIENT_APPROVED } enum ShotPriority { LOW NORMAL HIGH URGENT } enum ApprovalStatus { PENDING_REVIEW APPROVED REJECTED NEEDS_CHANGES } enum ReviewStatus { PENDING INTERNAL_APPROVED CLIENT_APPROVED NEEDS_CHANGES FINAL_APPROVED } enum NotificationType { VERSION_UPLOADED FEEDBACK_ADDED SHOT_APPROVED SHOT_REJECTED COMMENT_REPLY MENTION REVISION_REQUESTED TASK_ASSIGNED TASK_OVERDUE TASK_APPROVED TASK_CHANGES_REQUESTED TASK_READY_FOR_REVIEW } enum TaskStatus { TODO IN_PROGRESS INTERNAL_REVIEW CLIENT_REVIEW CHANGES DONE } enum TaskType { TRACK ROTO KEY COMP FX LIGHTING RENDER ANIMATION MODEL TEXTURE RIG LOOKDEV GENERAL } enum ProjectType { STANDARD EPISODIC } // ───────────────────────────────────────────── // RENDER PIPELINE ENUMS (RenderPipeline2 §5.1) // ───────────────────────────────────────────── enum ExportStatus { QUEUED RENDERING RENDER_FAILED VALIDATING VALIDATION_FAILED GENERATING_PREVIEW PREVIEW_FAILED READY_FOR_QC QC_FAILED READY_FOR_DELIVERY PACKAGED DELIVERED SUPERSEDED ARCHIVED CANCELLED } enum RenderJobStatus { QUEUED CLAIMED RUNNING COMPLETED FAILED CANCELLED EXPIRED } enum RenderJobType { AE_RENDER PREVIEW_ONLY DELIVERY_BUILD /// A standalone Blender render: a queue entry and nothing more. It has no /// Export, takes no shot version, and is not validated, QC'd or delivered — /// it is "Ctrl+F12, but on the farm". BLENDER_RENDER } enum ValidationStatus { PASS FAIL WARN SKIPPED } enum QCResult { PASS FAIL } enum MachineStatus { ONLINE OFFLINE DISABLED } /// What a package is for. A MOV_REVIEW package is a flat folder of preview /// MOVs shared for a formal review — it never touches the Export state /// machine, so a shot can appear in as many reviews as it takes before the /// EXR delivery that actually ships it. enum DeliveryKind { EXR_DELIVERY MOV_REVIEW } enum DeliveryStatus { DRAFT QUEUED BUILDING READY DELIVERED FAILED CANCELLED } enum TakeQuality { HERO GOOD PRINT NO_GOOD FALSE_START } enum AttachmentFileType { IMAGE VIDEO HDRI LIDAR LENS_GRID PDF OTHER } enum AttachmentCategory { SLATE CAMERA_POSITION WIDE_REFERENCE LENS LIGHTING HDRI TRACKING TEXTURE WITNESS MISCELLANEOUS } // ───────────────────────────────────────────── // AUTH MODELS (NextAuth v5 compatible) // ───────────────────────────────────────────── model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? image String? passwordHash String? role Role @default(ARTIST) isActive Boolean @default(true) mustChangePassword Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // NextAuth relations accounts Account[] sessions Session[] // App relations producedProjects Project[] @relation("ProducerProjects") supervisedProjects Project[] @relation("SupervisorProjects") assignedShots Shot[] @relation("ArtistShots") uploadedVersions Version[] comments Comment[] commentReplies CommentReply[] annotations Annotation[] approvals Approval[] notifications Notification[] clientAccess ClientAccess[] assignedTasks Task[] @relation("TaskArtist") createdTasks Task[] @relation("TaskCreator") sharedVersions Version[] @relation("VersionSharedBy") ledAssets Asset[] @relation("AssetLead") createdShootDays ShootDay[] @relation("ShootDayCreator") createdTakes Take[] @relation("TakeCreator") uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader") assignedBoardItems BoardItem[] @relation("BoardItemAssignee") @@map("users") } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) @@map("accounts") } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("sessions") } model VerificationToken { identifier String token String @unique expires DateTime @@unique([identifier, token]) @@map("verification_tokens") } // ───────────────────────────────────────────── // CORE DOMAIN MODELS // ───────────────────────────────────────────── model Client { id String @id @default(cuid()) company String contactPerson String email String @unique phone String? notes String? @db.Text logoUrl String? isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt projects Project[] clientAccess ClientAccess[] @@map("clients") } /// Links a USER with CLIENT role to the client record they represent model ClientAccess { id String @id @default(cuid()) userId String clientId String createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) @@unique([userId, clientId]) @@map("client_access") } model Project { id String @id @default(cuid()) name String code String @unique showId String @default("") projectType ProjectType @default(STANDARD) description String? @db.Text status ProjectStatus @default(ACTIVE) dueDate DateTime? startDate DateTime? clientId String? producerId String? supervisorId String? slackWebhook String? slackChannel String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt client Client? @relation(fields: [clientId], references: [id]) producer User? @relation("ProducerProjects", fields: [producerId], references: [id]) supervisor User? @relation("SupervisorProjects", fields: [supervisorId], references: [id]) shots Shot[] assets Asset[] tasks Task[] shotGroups ShotGroup[] reviewSessions ReviewSession[] episodeDueDates EpisodeDueDate[] shootDays ShootDay[] /// Per-production delivery naming/layout templates (RenderPipeline2 §11.2); /// falls back to SystemConfig defaults when null deliveryConfig Json? /// Per-production AE workspace: the show's slate/overlay template AEP, its /// renders root, and any comp names that depart from the {showId}_* naming /// convention. Read by both the panel and the headless preview build, which /// is why it lives here rather than in per-machine panel settings. pipelineConfig Json? deliveryPackages DeliveryPackage[] deliveryRequests DeliveryRequest[] /// The one-line "where are we" on the daily board ("Official bid sent", /// "Awaiting feedback on pick-ups"). Free text on purpose: it is what the /// producer would have typed at the top of the project's section in the doc. boardStatus String? @db.Text boardStatusAt DateTime? boardItems BoardItem[] milestones ProjectMilestone[] @@map("projects") } model EpisodeDueDate { id String @id @default(cuid()) projectId String episode String dueDate DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@unique([projectId, episode]) @@map("episode_due_dates") } model Shot { id String @id @default(cuid()) shotCode String /// Client-supplied naming alias, used only for slates, overlay burn-ins, /// and delivered file/folder names — never for internal paths, comp names /// or DB lookups, which always use shotCode. Null = use shotCode as today. clientCode String? scene String @default("") episode String? shotNumber Int @default(0) sequence String? description String? @db.Text notes String? @db.Text status ShotStatus @default(WAITING) priority ShotPriority @default(NORMAL) artistId String? projectId String frameStart Int? frameEnd Int? fps Float @default(24) dueDate DateTime? thumbnailUrl String? /// Thumbnail promoted from a render export — automatically when the project /// opts in via `pipelineConfig.autoShotThumbnail`, or by hand. Deliberately a /// separate column: `thumbnailUrl` is the client-supplied reference frame and /// is never written by the pipeline, so promotion can only fill a gap, never /// destroy the brief. Display precedence is `thumbnailUrl ?? generatedThumbnailUrl` /// (lib/shot-thumbnail.ts). generatedThumbnailUrl String? /// Version string of the export the generated thumbnail came from, e.g. "v004". generatedThumbnailFrom String? originalFootageUrl String? originalFootageKey String? shotGroupId String? shotApprovalStatus ShotApprovalStatus @default(PENDING) sharedWithClient Boolean @default(false) // EDL / pull CSV fields sourceClip String? timecodeStart String? timecodeEnd String? clipDuration String? exrOutput String? // Sequence / picture lock timecodes seqTimecodeStart String? seqTimecodeEnd String? // High-res deliverable (stored in Hetzner Object Storage) highResKey String? highResFilename String? // Shot-level version tracking (e.g. v001, v002) shotVersion String @default("v001") // Key shot flag — highlighted for prioritisation isKeyShot Boolean @default(false) /// The shot is scaled in the edit — the plate is not used at its native /// size. That changes how the shot is comped and reviewed, so it is a /// standing property of the shot rather than a note, and it is flagged on /// the shot-status board the way a key shot is. isScaled Boolean @default(false) /// Delivered outside the render pipeline — by hand, before packages /// existed. There is no Export to put in a DeliveryPackage, so the fact is /// recorded here instead and counted alongside DELIVERED EXR packages. The /// date is when the frames actually went out, not when it was recorded, so /// a backfill does not inflate the weekly delivery rate. manualDeliveredAt DateTime? manualDeliveredById String? manualDeliveryNote String? @db.Text /// Where a manual delivery was uploaded and who was sent the link. Optional /// for a backfill, but a manual delivery only closes a DeliveryRequest line /// when both are present — the same evidence a package needs. manualDeliveryUrl String? manualDeliverySharedWith String? // Netflix slate fields — managed via the Slate tab, used by the preview pipeline slateType String? slateDescription String? @db.Text slateVfxScope String? @db.Text slateSubmissionNote String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) artist User? @relation("ArtistShots", fields: [artistId], references: [id]) shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id]) versions Version[] tasks Task[] footagePlates FootagePlate[] references ShotReference[] loggedTakes Take[] @relation("TakeToShot") exports Export[] deliveryRequestLines DeliveryRequestLine[] boardItems BoardItem[] @@unique([projectId, shotCode]) @@map("shots") } model FootagePlate { id String @id @default(cuid()) shotId String label String @default("") fileUrl String fileKey String @default("") fileName String @default("") fileSize BigInt? sortOrder Int @default(0) createdAt DateTime @default(now()) shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade) @@map("footage_plates") } model ShotReference { id String @id @default(cuid()) shotId String label String? fileUrl String fileKey String @default("") fileName String @default("") fileSize BigInt? sortOrder Int @default(0) createdAt DateTime @default(now()) shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade) @@map("shot_references") } model ShotGroup { id String @id @default(cuid()) name String projectId String sortOrder Int @default(0) createdAt DateTime @default(now()) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) shots Shot[] @@unique([projectId, name]) @@map("shot_groups") } model Version { id String @id @default(cuid()) versionNumber Int shotId String? taskId String? artistId String? fileUrl String fileName String fileSize BigInt? mimeType String? thumbnailUrl String? posterUrl String? proxyUrl String? fps Float @default(24) duration Float? frameCount Int? width Int? height Int? /// Handles: frames rendered past the cut at each end (not in the edit). Null /// = not marked. The player flags them; the media is never changed. handleHead Int? handleTail Int? /// Frames of slate before the picture in this media. Null = not recorded /// (the player infers it from the media length vs frameCount). pictureStartFrame Int? /// High-quality review MP4 (full res, high bitrate) encoded from the /// delivery MOV — the player's "High" quality. Null = Standard only. hqFileUrl String? /// The delivery MOV (ProRes) itself, downloadable as this version's High /// Res file. Supersedes the shot-level manual highResKey where present. highResKey String? highResFilename String? highResSize BigInt? /// Set when housekeeping removed hqFileUrl/highResKey from storage. highResPrunedAt DateTime? notes String? @db.Text approvalStatus ApprovalStatus @default(PENDING_REVIEW) reviewStatus ReviewStatus @default(PENDING) isLatest Boolean @default(true) isClientVisible Boolean @default(false) sharedAt DateTime? sharedById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade) task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade) artist User? @relation(fields: [artistId], references: [id]) sharedBy User? @relation("VersionSharedBy", fields: [sharedById], references: [id]) comments Comment[] annotations Annotation[] approvals Approval[] @@map("versions") } model Comment { id String @id @default(cuid()) versionId String authorId String? frameNumber Int timestamp Float text String @db.Text isResolved Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt version Version @relation(fields: [versionId], references: [id], onDelete: Cascade) author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) replies CommentReply[] annotations Annotation[] @@map("comments") } model CommentReply { id String @id @default(cuid()) commentId String authorId String? text String @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) @@map("comment_replies") } /// Stores canvas drawing data as JSON (normalized 0-1 coordinates) model Annotation { id String @id @default(cuid()) versionId String commentId String? authorId String? frameNumber Int drawingData Json // Array of AnnotationShape objects color String @default("#ef4444") isVisible Boolean @default(true) createdAt DateTime @default(now()) version Version @relation(fields: [versionId], references: [id], onDelete: Cascade) comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull) author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) @@map("annotations") } model Approval { id String @id @default(cuid()) versionId String userId String? status ApprovalStatus notes String? @db.Text createdAt DateTime @default(now()) version Version @relation(fields: [versionId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@map("approvals") } model Notification { id String @id @default(cuid()) userId String type NotificationType title String message String @db.Text data Json? // Extra context (versionId, shotCode, etc.) isRead Boolean @default(false) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("notifications") } model Asset { id String @id @default(cuid()) projectId String assetCode String name String description String? @db.Text status ShotStatus @default(WAITING) priority ShotPriority @default(NORMAL) leadId String? dueDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) lead User? @relation("AssetLead", fields: [leadId], references: [id]) tasks Task[] @@unique([projectId, assetCode]) @@map("assets") } model Task { id String @id @default(cuid()) title String description String? @db.Text type TaskType @default(GENERAL) status TaskStatus @default(TODO) priority ShotPriority @default(NORMAL) dueDate DateTime? estimatedHours Float? sortOrder Int @default(0) shotId String? assetId String? assignedArtistId String? createdById String? projectId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt scheduledStartDate DateTime? scheduledEndDate DateTime? scheduleNotes String? @db.Text shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade) asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id]) createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) versions Version[] @@index([scheduledStartDate]) @@index([scheduledEndDate]) @@map("tasks") } /// Secure tokenized review link for clients model ReviewSession { id String @id @default(cuid()) projectId String token String @unique @default(cuid()) label String? email String? passwordHash String? expiresAt DateTime isActive Boolean @default(true) accessCount Int @default(0) allowedEpisodes String[] @default([]) createdAt DateTime @default(now()) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@map("review_sessions") } /// Key-value store for system-wide configuration (e.g. storage credentials) model SystemConfig { key String @id value String @db.Text updatedAt DateTime @updatedAt @@map("system_config") } /// Physical display event log — polled by ESP32 dot-matrix display model DisplayEvent { id Int @id @default(autoincrement()) type String // APPROVED | CHANGES | COMMENT | REJECTED shotCode String by String // actor name (reviewer / commenter) createdAt DateTime @default(now()) @@index([createdAt]) @@map("display_events") } // ───────────────────────────────────────────── // SHOOT LOG MODELS // ───────────────────────────────────────────── model ShootDay { id String @id @default(cuid()) projectId String date DateTime unit String @default("A") label String? notes String? @db.Text createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull) setups Setup[] @@map("shoot_days") } model Setup { id String @id @default(cuid()) shootDayId String name String description String? @db.Text sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt shootDay ShootDay @relation(fields: [shootDayId], references: [id], onDelete: Cascade) takes Take[] @@map("setups") } model Take { id String @id @default(cuid()) setupId String takeNumber Int // Optional link back to a pipeline shot pipelineShotId String? // General scene String? shotLabel String? unitLabel String? // Camera cameraLetter String? clipName String? roll String? cameraModel String? resolution String? codec String? fps Float? shutter String? iso Int? whiteBalance Int? colourSpace String? // Lens lensSet String? lens String? tStop String? filters String? isAnamorphic Boolean @default(false) // Tracking hasHdri Boolean @default(false) hasChromeBall Boolean @default(false) hasGreyBall Boolean @default(false) hasMacbeth Boolean @default(false) hasCleanPlate Boolean @default(false) hasSurvey Boolean @default(false) hasLidar Boolean @default(false) hasWitnessCamera Boolean @default(false) hasLensGrid Boolean @default(false) hasTexturePhotos Boolean @default(false) hasPhotogrammetry Boolean @default(false) // Environment weather String? sunDirection String? artificialLights String? @db.Text // Supervisor supervisorNotes String? @db.Text continuityNotes String? @db.Text vfxRequirements String? @db.Text quality TakeQuality @default(PRINT) createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt setup Setup @relation(fields: [setupId], references: [id], onDelete: Cascade) pipelineShot Shot? @relation("TakeToShot", fields: [pipelineShotId], references: [id], onDelete: SetNull) createdBy User? @relation("TakeCreator", fields: [createdById], references: [id], onDelete: SetNull) attachments TakeAttachment[] @@unique([setupId, takeNumber]) @@map("takes") } model TakeAttachment { id String @id @default(cuid()) takeId String fileUrl String fileKey String @default("") fileName String fileSize BigInt? fileType AttachmentFileType @default(IMAGE) category AttachmentCategory @default(MISCELLANEOUS) caption String? sortOrder Int @default(0) uploadedById String? createdAt DateTime @default(now()) take Take @relation(fields: [takeId], references: [id], onDelete: Cascade) uploadedBy User? @relation("TakeAttachmentUploader", fields: [uploadedById], references: [id], onDelete: SetNull) @@map("take_attachments") } // ───────────────────────────────────────────── // RENDER PIPELINE MODELS (RenderPipeline2 §5) // Phases 1 + 2: Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat. // ValidationResult / QCReview / DeliveryPackage / DeliveryItem arrive in // later phases as additive migrations. // ───────────────────────────────────────────── /// One row per "Queue Export" click — carries the §4 state machine. model Export { id String @id @default(cuid()) shotId String shot Shot @relation(fields: [shotId], references: [id]) projectId String taskId String? // comp task the preview Version attaches to versionId String? // preview Version created at READY_FOR_QC (Phase 4) versionNumber Int // 4 versionString String // "v004" status ExportStatus @default(QUEUED) statusChangedAt DateTime @default(now()) // Manifest (denormalised for querying; full manifest JSON on RenderJob) aepPath String compName String rendererType String // "aerender" outputDir String // render root for this export outputPattern String // "UNG_106_010_020_cmp_TT_v004.[####].exr" frameStart Int frameEnd Int fps Float width Int height Int colorspace String? // expected, e.g. "ACES - ACEScg" // Artifacts deliveryMovPath String? // slate/burn-in delivery MOV on the SAN (§9) previewMovKey String? // web H.264 transcode in object storage thumbnailKey String? metadataKey String? // metadata JSON in object storage exrFileCount Int? exrTotalBytes BigInt? checksum String? // sequence-level digest (xxHash of per-file hashes) /// Full-duration (handles included), ungraded MP4 of just the picture-lock /// plate — a diagnostic reference to check back against, not a reviewable /// deliverable, so it lives here rather than as a Version. piclockRefKey String? // Slate fields authored per submission (not per shot): they change from one // delivery to the next, so each Export carries its own and new exports // default to the previous export's values. vfxScope String? @db.Text submissionNote String? @db.Text /// Render the EXRs but build no preview, inheriting the previous export's /// review media instead. For an export whose framing differs from the review /// framing — unscaled or un-punched-in plates — the earlier MOV is the /// correct preview, and rebuilding it from these EXRs would be wrong. skipPreview Boolean @default(false) /// Handles the render carries (frames past the cut at each end), from the /// panel's "Includes handles" setting. Copied onto the preview Version so /// the player can flag them; never used to trim or alter any render. handleHead Int? handleTail Int? /// Skip picture-lock trimming and the picture-lock reference render for this /// export — set when picture lock is missing or wrong for the shot, so the /// preview build falls back to the untrimmed full render range instead of /// trimming against bad data. noPictureLock Boolean @default(false) submittedById String? // resolved User, else null submittedByName String? // free text from panel config as fallback supersededById String? // newer Export that replaced this one renderJobs RenderJob[] events ExportEvent[] validations ValidationResult[] qcReviews QCReview[] deliveryItems DeliveryItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([shotId, versionNumber]) @@index([status]) @@index([projectId, status]) @@map("exports") } /// One execution attempt of work by a worker. Also used (type DELIVERY_BUILD) /// for delivery package builds — one queue/claim/lease/heartbeat mechanism. model RenderJob { id String @id @default(cuid()) type RenderJobType @default(AE_RENDER) exportId String? // set for AE_RENDER / PREVIEW_ONLY export Export? @relation(fields: [exportId], references: [id]) deliveryId String? // set for DELIVERY_BUILD (Phase 5) /// Set for jobs that belong to a project but not to a shot — a standalone /// Blender render. Lets the queue be filtered by production without /// inventing an Export for work that has no shot. projectId String? /// Human label for such a job, since there is no shot code to show. label String? submittedBy String? // email/name of whoever queued it attempt Int @default(1) maxAttempts Int @default(3) status RenderJobStatus @default(QUEUED) priority Int @default(50) // lower = sooner; <= 20 is urgent manifest Json // full Render Manifest snapshot (§6.2) machineId String? machine Machine? @relation(fields: [machineId], references: [id]) claimedAt DateTime? leaseExpiresAt DateTime? // claim + leaseSeconds; renewed by progress reports startedAt DateTime? finishedAt DateTime? progress Float @default(0) // 0..1 currentFrame Int? totalFrames Int? etaSeconds Int? exitCode Int? errorMessage String? logTail String? @db.Text // last ~200 lines of aerender output logFileKey String? // full log uploaded to object storage on finish/fail createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([status, priority, createdAt]) @@index([machineId, status]) @@index([projectId, status]) @@map("render_jobs") } /// Append-only audit log of Export state transitions (§5.9) model ExportEvent { id String @id @default(cuid()) exportId String export Export @relation(fields: [exportId], references: [id]) fromStatus String? toStatus String actorType String // "WORKER" | "USER" | "SYSTEM" actorId String? // machineId or userId note String? createdAt DateTime @default(now()) @@index([exportId, createdAt]) @@map("export_events") } /// A render-capable machine (artist workstation or future render node) (§5.6) model Machine { id String @id @default(cuid()) name String @unique // "RENDER-01" hostname String status MachineStatus @default(OFFLINE) enabled Boolean @default(true) // admin kill-switch: disabled machines cannot claim lastSeenAt DateTime? workerVersion String? aeVersion String? // "2026 (24.x)" capabilities Json? // { maxConcurrentJobs: 1, tools: {...} } availability Json? // §7.10: { mode, windows, allowUrgentAnytime } — null = ALWAYS renderNowUntil DateTime? // manual "Render Now" override; claims allowed until this time apiKeyHash String? // optional per-machine key (dormant; §15 — shared key for now) renderJobs RenderJob[] heartbeats WorkerHeartbeat[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("machines") } model WorkerHeartbeat { id String @id @default(cuid()) machineId String machine Machine @relation(fields: [machineId], references: [id]) createdAt DateTime @default(now()) cpuPercent Float? memPercent Float? diskFreeGb Float? currentJobId String? @@index([machineId, createdAt]) @@map("worker_heartbeats") } /// What version of each studio tool is running where. Written by the tool /// itself on startup, so it reflects what is *deployed* rather than what /// someone remembered to write down. One row per (tool, machine). model ToolVersion { id String @id @default(cuid()) tool String // "AE_PANEL" | "RENDER_WORKER" machineName String version String // "2.1.0" buildRef String? // git sha / build stamp, when the tool can report one hostUser String? // artist email, or the worker's service account hostApp String? // "After Effects 24.6" / OS build details Json? firstSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now()) @@unique([tool, machineName]) @@index([tool]) @@map("tool_versions") } /// One row per check per validation run, so the UI shows exactly what failed /// and history survives retries (§5.4). model ValidationResult { id String @id @default(cuid()) exportId String export Export @relation(fields: [exportId], references: [id]) renderJobId String // which attempt produced this run checkName String // "timecode_plate_match", "missing_frames", ... status ValidationStatus expected String? actual String? message String? details Json? // e.g. list of missing frame numbers, or the timecode reference used createdAt DateTime @default(now()) @@index([exportId, renderJobId]) @@map("validation_results") } /// Human technical QC on an Export (§5.5). Multiple rows allowed — the latest /// is authoritative and drives T13/T14. model QCReview { id String @id @default(cuid()) exportId String export Export @relation(fields: [exportId], references: [id]) result QCResult notes String? @db.Text reviewedById String? // resolved User where possible reviewerName String? // fallback for API-key reviews from the panel source String // "AE_PANEL" | "WEB" createdAt DateTime @default(now()) @@index([exportId]) @@map("qc_reviews") } /// A dated delivery folder built from READY_FOR_DELIVERY exports (§5.7). /// Never deleted — a mistaken package is CANCELLED or superseded. model DeliveryPackage { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id]) episode String // "101" — empty string on standard (non-episodic) shows, and on reviews spanning several name String // "20260805_Delivery" | "20260828_Producer_review_02" kind DeliveryKind @default(EXR_DELIVERY) packageNumber Int // sequential per project → "Package #15" status DeliveryStatus @default(DRAFT) rootPath String? // final path on the delivery volume configSnapshot Json // naming template + options at build time (immutable record) manifest Json? // built manifest: files, sizes, checksums reportKey String? // delivery report in object storage; also written into the folder errorMessage String? createdById String createdAt DateTime @default(now()) builtAt DateTime? // Handoff evidence. Built is not delivered: the folder still has to be // uploaded to the client's portal and the link sent to a person, and each of // those is recorded separately so a package cannot be "delivered" on a hunch. uploadUrl String? // where it lives on the client's side (Content Hub folder) uploadedAt DateTime? uploadedById String? sharedWith String? // who was sent the link sharedNote String? deliveredAt DateTime? // when the link was shared — the package is DELIVERED from here deliveredById String? items DeliveryItem[] @@unique([projectId, packageNumber]) @@index([projectId, episode]) @@index([projectId, kind]) @@index([status]) @@map("delivery_packages") } /// Version lock: the exact Export delivered. shotCode/versionString are /// denormalised so a later shot rename or re-render never mutates history. model DeliveryItem { id String @id @default(cuid()) packageId String package DeliveryPackage @relation(fields: [packageId], references: [id], onDelete: Cascade) exportId String export Export @relation(fields: [exportId], references: [id]) shotId String shotCode String /// Snapshot of Shot.clientCode at package build time — same immutability /// rule as shotCode/versionString: a later shot edit must not retroactively /// change already-built delivery history. clientCode String? versionString String exrPath String? // path inside the package movPath String? fileCount Int? totalBytes BigInt? checksum String? linkMode String? // "hardlink" | "copy" — what the builder actually did status String @default("PENDING") // PENDING | COPIED | VERIFIED | FAILED errorMessage String? @@unique([packageId, exportId]) @@index([exportId]) @@map("delivery_items") } /// Something a client asked for ("EXRs for 116"), logged by hand because the /// ask arrives over chat. It has no status of its own: a line is closed when /// its shot has gone out with evidence after the request was logged, and the /// request is closed when every line is — so it cannot be ticked off by hand. model DeliveryRequest { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) requestNumber Int // sequential per project → "Request #4" episode String @default("") // set when a whole episode was asked for deliverable String @default("EXR") // EXR | MOV | EXR_AND_MOV — what the client asked for requestedBy String // who at the client asked note String? @db.Text dueDate DateTime? createdById String createdAt DateTime @default(now()) cancelledAt DateTime? cancelledById String? cancelReason String? lines DeliveryRequestLine[] @@unique([projectId, requestNumber]) @@index([projectId, cancelledAt]) @@map("delivery_requests") } /// One requested shot. The shot list is a snapshot taken when the request was /// logged; codes are denormalised for the same reason DeliveryItem's are. model DeliveryRequestLine { id String @id @default(cuid()) requestId String request DeliveryRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) shotId String shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade) shotCode String clientCode String? @@unique([requestId, shotId]) @@index([shotId]) @@map("delivery_request_lines") } /// A line on the daily board — the day-to-day to-do the team used to keep in /// a shared doc ("DH2_VFX_027_090 finalise (CHRIS)"). Deliberately not a Task: /// tasks are the pipeline's one-per-shot work items whose state drives shot /// status, and a note like "finalise" or "awaiting gfx" must not move a shot /// back to IN_PROGRESS or notify anyone. /// /// `code` is the shot code as typed. When it matches a shot, `shotId` links it /// and the board shows the shot's live status; when it does not, the line is a /// flag ("not on our tracker?") until the shot exists or the code is fixed. model BoardItem { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) shotId String? shot Shot? @relation(fields: [shotId], references: [id], onDelete: SetNull) code String? text String @db.Text /// Heading the line sits under on the board ("WATER DROPS"); "" = none group String @default("") assigneeId String? assignee User? @relation("BoardItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) /// What the line is blocked on ("client GFX ADH_GFX_013_010"); null = not blocked waitingOn String? sortOrder Int @default(0) doneAt DateTime? doneById String? createdById String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([projectId, doneAt]) @@index([shotId]) @@map("board_items") } /// A dated project milestone — batch deliveries, picture locks, shoot start, /// colour & mix review. Episode due dates stay in EpisodeDueDate because the /// shot-status board keys off them; these are the dates around the work. model ProjectMilestone { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) label String date DateTime doneAt DateTime? createdAt DateTime @default(now()) @@index([projectId, date]) @@map("project_milestones") } model SketchTemplate { id String @id @default(cuid()) name String fileUrl String fileKey String @default("") fileName String @default("") fileSize BigInt? sortOrder Int @default(0) createdAt DateTime @default(now()) @@map("sketch_templates") }