dow-prod-tracker/prisma/schema.prisma
DJP 96be120525 OMG number moves from Project to Deliverable for inbound matching
The leading digits in the inbound filename convention identify the
deliverable, not the project. Adds Deliverable.omgJobNumber (unique
per org), flips inbound-ingest-service to resolve the deliverable
directly by that number (no more project hop), points box-outbound's
folder name at the deliverable's OMG, and surfaces the field in the
deliverable form + detail page.

Schema:
  - NEW Deliverable.omgJobNumber String?
  - @@index([omgJobNumber]) + @@unique([organizationId, omgJobNumber])
  - Migration 20260512400000_deliverable_omg_number

Inbound matcher:
  - Skips project lookup, resolves deliverable directly.
  - Slug capture from the filename becomes a sanity-check warning
    instead of a hard requirement (OMG # is authoritative now).
  - Unmatched message reads "No deliverable found with OMG # X".

Outbound:
  - buildDeliveryNaming now takes deliverable.omgJobNumber.
  - "Missing OMG number on deliverable" failure case replaces the
    old project-level check.

UI:
  - Deliverable form dialog: new "OMG Deliverable #" input with a
    one-line hint explaining the inbound matching convention.
  - Deliverable detail metadata: shows "OMG Deliverable #" alongside
    the existing "OMG Project #" (relabelled from "OMG Job #").

Project.omgJobNumber is kept as-is — still used for OMG webhook
project upserts; just no longer drives the file matcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:01:02 -04:00

1259 lines
41 KiB
Text

generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ─── Enums ──────────────────────────────────────────────
enum Role {
ADMIN
PRODUCER
ARTIST
CLIENT_VIEWER
}
enum ProjectStatus {
PIPELINE
ACTIVE
ON_HOLD
COMPLETED
CANCELED
ARCHIVED
}
enum BriefStatus {
PENDING // just arrived
REVIEWING // producer is triaging
ACCEPTED // approved but not yet a project
REJECTED // declined
CONVERTED // promoted to a Project (see convertedProjectId)
ARCHIVED
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}
enum DeliverableStatus {
NOT_STARTED
IN_PROGRESS
IN_REVIEW
APPROVED
ON_HOLD
}
enum StageStatus {
BLOCKED
NOT_STARTED
IN_PROGRESS
IN_REVIEW
CHANGES_REQUESTED
APPROVED
DELIVERED
SKIPPED
}
enum RevisionStatus {
SUBMITTED
IN_REVIEW
CHANGES_REQUESTED
APPROVED
}
enum NotificationType {
ASSIGNMENT
STATUS_CHANGE
REVISION_SUBMITTED
REVISION_FEEDBACK
COMMENT
DEADLINE_APPROACHING
DEADLINE_OVERDUE
STAGE_UNBLOCKED
BOX_UNMATCHED_FILE
NEW_FILE_AWAITING_REVIEWER
}
enum BoxPushStatus {
PENDING
SUCCESS
FAILED
}
enum BoxInboundStatus {
MATCHED
UNMATCHED
ERROR
}
enum AssignmentRole {
LEAD
SUPPORT
}
enum SkillLevel {
JUNIOR
INTERMEDIATE
SENIOR
LEAD
}
// ─── RBAC ──────────────────────────────────────────────
model OrgRolePermission {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
role Role
permission Permission
@@unique([organizationId, role, permission])
@@index([organizationId])
@@map("org_role_permissions")
}
// ─── Organization ───────────────────────────────────────
model Organization {
id String @id @default(cuid())
name String
domain String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
projects Project[]
automationRules AutomationRule[]
rolePermissions OrgRolePermission[]
pipelineTemplates PipelineTemplate[]
deliverables Deliverable[]
deliverableStages DeliverableStage[]
invitations Invitation[]
customFieldDefs CustomFieldDefinition[]
notificationRules NotificationRule[]
clientTeams ClientTeam[]
pods Pod[]
resourceBookings ResourceBooking[]
briefs Brief[]
@@map("organizations")
}
// ─── Auth.js models ─────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
role Role @default(ARTIST)
department String?
maxCapacity Int @default(5)
// Local auth (MVP) — Entra SSO coexists via nullable passwordHash
passwordHash String?
passwordResetToken String? @unique
passwordResetExpires DateTime?
mustChangePassword Boolean @default(true)
lastLoginAt DateTime?
isExternal Boolean @default(false) // fast filter for CLIENT_VIEWER users
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
homePodId String?
homePod Pod? @relation("PodHome", fields: [homePodId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
assignments StageAssignment[]
comments Comment[]
notifications Notification[]
skills UserSkill[]
searchLogs SearchLog[]
automationRules AutomationRule[] @relation("AutomationCreator")
chatMessages ChatMessage[]
invitationsSent Invitation[] @relation("InvitedBy")
annotations Annotation[]
clientTeams ClientTeamMembership[]
podsLed Pod[] @relation("PodLead")
bookings ResourceBooking[] @relation("BookingResource")
bookingsCreated ResourceBooking[] @relation("BookingCreator")
attachmentsCreated DeliverableAttachment[]
projectsOwned Project[] @relation("ProjectOwner")
feedbackCreated FeedbackItem[] @relation("FeedbackCreator")
feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee")
feedbackResolved FeedbackItem[] @relation("FeedbackResolver")
feedbackVerified FeedbackItem[] @relation("FeedbackVerifier")
reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator")
reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider")
@@index([homePodId])
@@index([isExternal])
@@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
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
// ─── Pipeline Templates (seed data) ────────────────────
model PipelineStageTemplate {
id String @id @default(cuid())
name String @unique
slug String @unique
order Int @unique
isCriticalGate Boolean @default(false)
isOptional Boolean @default(false)
description String?
estimatedDays Float?
approvalType ApprovalType @default(NONE)
dependsOn PipelineStageDependency[] @relation("DependsOnStage")
dependedBy PipelineStageDependency[] @relation("PrerequisiteStage")
deliverableStages DeliverableStage[]
skillRequirements StageSkillRequirement[]
@@map("pipeline_stage_templates")
}
model PipelineStageDependency {
id String @id @default(cuid())
stageId String
prerequisiteId String
stage PipelineStageTemplate @relation("DependsOnStage", fields: [stageId], references: [id])
prerequisite PipelineStageTemplate @relation("PrerequisiteStage", fields: [prerequisiteId], references: [id])
@@unique([stageId, prerequisiteId])
@@map("pipeline_stage_dependencies")
}
// ─── Dynamic Pipeline Templates (org-scoped) ───────────
model PipelineTemplate {
id String @id @default(cuid())
name String
description String?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
isArchived Boolean @default(false)
isDefault Boolean @default(false)
// Regex applied to incoming filenames (Box webhook / API upload / manual
// upload-by-filename) to extract OMG # + slug + optional version.
// Null = use the system default
// `^(\d+)_([a-z0-9-]+)(?:_v(\d+))?(?:\.[a-z0-9]+)?$`. Editable per pipeline
// from the pipeline editor. See external-delivery-service.parseInboundFileName.
inboundFilenameRegex String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stages PipelineStageDefinition[]
projects Project[]
@@unique([organizationId, name])
@@index([organizationId])
@@map("pipeline_templates")
}
model PipelineStageDefinition {
id String @id @default(cuid())
pipelineId String
pipeline PipelineTemplate @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
name String
slug String
order Int
isCriticalGate Boolean @default(false)
isOptional Boolean @default(false)
description String?
estimatedDays Float?
color String?
customStatuses Json?
approvalType ApprovalType @default(NONE)
dependsOn PipelineStageDependencyV2[] @relation("DependsOnStageV2")
dependedBy PipelineStageDependencyV2[] @relation("PrerequisiteStageV2")
// Declared "rework" / pushback paths — explicit opt-in backward
// transitions for rejection handling. Example: from Final Approval you
// can push back to In Progress Creative when something fails approval.
// Without an entry here a stage can ONLY move forward through the
// pipeline (along with the existing dependsOn graph).
reworkFrom PipelineStageRework[] @relation("ReworkFromStage")
reworkTo PipelineStageRework[] @relation("ReworkToStage")
deliverableStages DeliverableStage[]
deliverableAttachments DeliverableAttachment[]
@@unique([pipelineId, slug])
@@unique([pipelineId, order])
@@map("pipeline_stage_definitions")
}
model PipelineStageDependencyV2 {
id String @id @default(cuid())
stageId String
prerequisiteId String
stage PipelineStageDefinition @relation("DependsOnStageV2", fields: [stageId], references: [id], onDelete: Cascade)
prerequisite PipelineStageDefinition @relation("PrerequisiteStageV2", fields: [prerequisiteId], references: [id], onDelete: Cascade)
@@unique([stageId, prerequisiteId])
@@map("pipeline_stage_dependencies_v2")
}
// Explicit backward "rework" transitions on a pipeline template.
// Distinct from dependsOn (which captures forward prerequisites): this
// table captures "from stage X you are allowed to push a deliverable
// back to stage Y". Board drag validates drops against this list.
model PipelineStageRework {
id String @id @default(cuid())
fromStageId String
toStageId String
fromStage PipelineStageDefinition @relation("ReworkFromStage", fields: [fromStageId], references: [id], onDelete: Cascade)
toStage PipelineStageDefinition @relation("ReworkToStage", fields: [toStageId], references: [id], onDelete: Cascade)
@@unique([fromStageId, toStageId])
@@index([fromStageId])
@@map("pipeline_stage_reworks")
}
// ─── Project ────────────────────────────────────────────
model Project {
id String @id @default(cuid())
projectCode String @unique
name String
description String?
status ProjectStatus @default(ACTIVE)
priority Priority @default(MEDIUM)
startDate DateTime?
dueDate DateTime?
businessUnit String?
formFactor String?
codeName String?
npiOrRefresh String?
quarter String?
// Freeform owner name from XLSX/webhook intake. Used as a fallback
// label when no matching user exists on the system (e.g. the upstream
// Owner column contains a client-side name). Once we can link it to
// a real user, populate requestorUserId and display that instead.
requestor String?
requestorUserId String?
workfrontId String?
omgCode String?
bmtId String?
estimatedCost Float?
actualCost Float?
agency String?
// Dow-specific: upstream OMG job number (canonical key for XLSX + webhook ingest)
omgJobNumber String? @unique
// pgvector embedding for semantic search (raw SQL — Prisma can't query this directly)
embedding Unsupported("vector(768)")?
customFields Json?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
clientTeamId String?
clientTeam ClientTeam? @relation(fields: [clientTeamId], references: [id])
requestorUser User? @relation("ProjectOwner", fields: [requestorUserId], references: [id])
pipelineTemplateId String?
pipelineTemplate PipelineTemplate? @relation(fields: [pipelineTemplateId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliverables Deliverable[]
convertedFrom Brief[] @relation("BriefConvertedProject")
@@index([organizationId])
@@index([clientTeamId])
@@index([pipelineTemplateId])
@@index([status])
@@map("projects")
}
// ─── Deliverable ────────────────────────────────────────
model Deliverable {
id String @id @default(cuid())
name String
// OMG's per-deliverable identifier. This is what shows up as the
// leading digits in the inbound filename convention — e.g.
// "77777778_homepage-banner_v2.jpg" means OMG deliverable #77777778.
// Unique per organisation. Optional for legacy / manually-created rows
// until an OMG number is assigned.
omgJobNumber String?
status DeliverableStatus @default(NOT_STARTED)
priority Priority @default(MEDIUM)
dueDate DateTime?
notes String?
cmfSku String?
assetCount Int?
requestedDueDate DateTime?
plannedDeliveryDate DateTime?
actualDeliveryDate DateTime?
wfInputDate DateTime?
// pgvector embedding for semantic search (raw SQL — Prisma can't query this directly)
embedding Unsupported("vector(768)")?
customFields Json?
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stages DeliverableStage[]
attachments DeliverableAttachment[]
boxPushLogs BoxPushLog[]
holdingPenFiles HoldingPenFile[]
// Optional override for the inbound matcher slug. Producers can pin an
// alias if the auto-slugified deliverable name doesn't match what arrives
// via Box / API. Default null = use slugify(name).
boxAliasSlug String?
@@index([projectId])
@@index([organizationId])
@@index([status])
@@index([omgJobNumber])
@@unique([organizationId, omgJobNumber])
@@map("deliverables")
}
// ─── Deliverable Attachments (files + external links) ───
//
// Producers upload PDFs, design exports, and other asset files; they
// also paste Figma / Drive / Dropbox links that should live alongside.
// Two kinds share one row: file (uploaded to /data/uploads/deliverables)
// vs link (external URL). An optional stageDefinitionId tags an asset
// to a specific pipeline stage; null means it applies to the whole
// deliverable.
model DeliverableAttachment {
id String @id @default(cuid())
deliverableId String
deliverable Deliverable @relation(fields: [deliverableId], references: [id], onDelete: Cascade)
kind String // "file" | "link"
title String
url String // local /api/uploads/... or external https://...
mimeType String?
fileSize Int?
thumbnailUrl String?
stageDefinitionId String?
stageDefinition PipelineStageDefinition? @relation(fields: [stageDefinitionId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([deliverableId])
@@index([stageDefinitionId])
@@map("deliverable_attachments")
}
// ─── Deliverable Stage (instance per deliverable) ───────
model DeliverableStage {
id String @id @default(cuid())
status StageStatus @default(BLOCKED)
revisionRound Int @default(0)
startDate DateTime?
completedDate DateTime?
dueDate DateTime?
notes String?
subStatus String?
manualSchedule Boolean @default(false)
scheduleConflict Boolean @default(false)
scheduleDelta Int?
deliverableId String
deliverable Deliverable @relation(fields: [deliverableId], references: [id], onDelete: Cascade)
templateId String
template PipelineStageTemplate @relation(fields: [templateId], references: [id])
stageDefinitionId String?
stageDefinition PipelineStageDefinition? @relation(fields: [stageDefinitionId], references: [id])
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments StageAssignment[]
revisions Revision[]
comments Comment[]
feedbackItems FeedbackItem[]
reviewSessionItems ReviewSessionItem[]
suggestedHoldingPenFiles HoldingPenFile[]
@@unique([deliverableId, stageDefinitionId])
@@index([deliverableId])
@@index([stageDefinitionId])
@@index([organizationId])
@@index([status])
@@map("deliverable_stages")
}
// ─── Stage Assignment ───────────────────────────────────
model StageAssignment {
id String @id @default(cuid())
role AssignmentRole? @default(LEAD)
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@unique([deliverableStageId, userId])
@@index([userId])
@@map("stage_assignments")
}
// ─── Revision ───────────────────────────────────────────
//
// Versioning model:
// - `major` starts at 0; bumps to 1 on first "Send to client"; etc.
// - `minor` counts internal iterations within the current major. Resets to 0
// on send.
// - `sentToClient + minor === 0` means this revision IS a client-visible
// V{major}. Otherwise it's an internal V{major}.{minor}.
// - One asset per revision — image OR video — stored in the `asset` JSON.
// Kind auto-detected from MIME at upload time. References, multi-key
// attachments, and screenshots-as-asset are gone (screenshots remain a
// separate annotation feature).
model Revision {
id String @id @default(cuid())
status RevisionStatus @default(SUBMITTED)
feedbackNotes String?
internalNotes String?
// Per-stage version coordinates. See block comment above for semantics.
major Int @default(0)
minor Int @default(1)
sentToClient Boolean @default(false)
sentAt DateTime?
// Single asset: { kind: "image" | "video", url, mimeType, size }.
// Null when the row exists but no upload has happened yet.
asset Json?
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
annotations Annotation[]
feedbackItems FeedbackItem[]
reviewSessionItems ReviewSessionItem[]
boxPushLogs BoxPushLog[]
// Set when this revision's asset has been pushed to Box (outbound).
// Format: the Box folder id under which the asset was uploaded.
boxFolderId String?
@@unique([deliverableStageId, major, minor])
@@index([deliverableStageId])
@@map("revisions")
}
// ─── Holding Pen ────────────────────────────────────────
//
// Files that arrive (via Box webhook / API / manual upload-by-filename) for
// a deliverable that's currently IN_REVIEW. They don't overwrite the
// in-flight V{n} — they wait here until the producer manually promotes them
// to a new revision (creating the next minor in the chain) or discards.
enum HoldingPenSource {
MANUAL
API
BOX
}
model HoldingPenFile {
id String @id @default(cuid())
deliverableId String
deliverable Deliverable @relation(fields: [deliverableId], references: [id], onDelete: Cascade)
// Best-guess stage at intake time. Producer can override on promote.
suggestedStageId String?
suggestedStage DeliverableStage? @relation(fields: [suggestedStageId], references: [id])
fileName String
asset Json // { kind: "image"|"video", url, mimeType, size }
source HoldingPenSource
parsedOmgNumber String?
parsedSlug String?
parsedVersion Int?
receivedAt DateTime @default(now())
@@index([deliverableId])
@@map("holding_pen_files")
}
// ─── Comment ────────────────────────────────────────────
model Comment {
id String @id @default(cuid())
content String @db.Text
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id])
parentId String?
parent Comment? @relation("CommentThread", fields: [parentId], references: [id])
replies Comment[] @relation("CommentThread")
annotations Annotation[]
feedbackItems FeedbackItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([deliverableStageId])
@@index([parentId])
@@map("comments")
}
// ─── Notification ───────────────────────────────────────
model Notification {
id String @id @default(cuid())
type NotificationType
title String
message String
link String?
isRead Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId, isRead])
@@map("notifications")
}
// ─── Skills & Capacity (Phase 6) ────────────────────────
model Skill {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
users UserSkill[]
stageRequirements StageSkillRequirement[]
@@map("skills")
}
model UserSkill {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
skillId String
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
level SkillLevel @default(INTERMEDIATE)
@@id([userId, skillId])
@@map("user_skills")
}
model StageSkillRequirement {
stageTemplateId String
stageTemplate PipelineStageTemplate @relation(fields: [stageTemplateId], references: [id], onDelete: Cascade)
skillId String
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
importance Int @default(1) // 1=nice-to-have, 2=important, 3=required
@@id([stageTemplateId, skillId])
@@map("stage_skill_requirements")
}
// ─── Automation Engine (Phase 7.1) ──────────────────────
model AutomationRule {
id String @id @default(cuid())
name String
description String?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
isEnabled Boolean @default(true)
trigger Json // { event, conditions[] }
actions Json // [{ type, params }]
createdById String
createdBy User @relation("AutomationCreator", fields: [createdById], references: [id])
executions AutomationExecution[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([isEnabled])
@@map("automation_rules")
}
model AutomationExecution {
id String @id @default(cuid())
ruleId String
rule AutomationRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
triggeredBy Json // the event payload that triggered execution
result Json // what actions were taken + outcomes
status ExecutionStatus
error String?
executedAt DateTime @default(now())
@@index([ruleId])
@@index([executedAt])
@@map("automation_executions")
}
enum ExecutionStatus {
SUCCESS
PARTIAL_FAILURE
FAILURE
}
enum Permission {
PROJECT_CREATE
PROJECT_UPDATE
PROJECT_DELETE
PROJECT_VIEW
DELIVERABLE_VIEW
DELIVERABLE_CREATE
DELIVERABLE_UPDATE
DELIVERABLE_DELETE
STAGE_VIEW
STAGE_UPDATE
STAGE_UPDATE_STATUS
STAGE_ASSIGN
STAGE_SCHEDULE
REVISION_CREATE
REVISION_UPDATE
REVISION_REVIEW
COMMENT_CREATE
COMMENT_DELETE
COMMENT_DELETE_ANY
PIPELINE_MANAGE
USER_MANAGE
ROLE_MANAGE
ORG_SETTINGS
AUTOMATION_MANAGE
FIELD_CUSTOMIZE
CLIENT_TEAM_MANAGE
POD_MANAGE
}
// ─── Chat History (CLI Anything) ────────────────────────
model ChatMessage {
id String @id @default(cuid())
sessionId String
role String // "user" | "assistant" | "system"
content String @db.Text
toolCalls Json? // tool calls made by assistant
toolResults Json? // results of tool execution
metadata Json? // context: active project, etc.
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organizationId String
createdAt DateTime @default(now())
@@index([sessionId])
@@index([userId])
@@map("chat_messages")
}
// ─── Custom Fields ──────────────────────────────────────
model CustomFieldDefinition {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
entityType String // "PROJECT" | "DELIVERABLE"
fieldName String
fieldType String // "TEXT" | "NUMBER" | "DATE" | "SELECT" | "BOOLEAN"
fieldOptions Json? // For SELECT type: { options: string[] }
isRequired Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, entityType, fieldName])
@@index([organizationId])
@@map("custom_field_definitions")
}
// ─── Notification Rules ─────────────────────────────────
model NotificationRule {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isEnabled Boolean @default(true)
event String // e.g. "STAGE_STATUS_CHANGE", "DEADLINE_APPROACHING", "REVISION_SUBMITTED"
conditions Json? // { field: string, operator: string, value: any }[]
channels Json // ["IN_APP", "EMAIL"]
recipientRoles Json // ["ADMIN", "PRODUCER"] or ["ASSIGNEE"]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([event])
@@map("notification_rules")
}
// ─── Invitations ────────────────────────────────────────
model Invitation {
id String @id @default(cuid())
email String
role Role @default(ARTIST)
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
invitedById String
invitedBy User @relation("InvitedBy", fields: [invitedById], references: [id])
token String @unique @default(cuid())
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
@@unique([email, organizationId])
@@index([organizationId])
@@index([token])
@@map("invitations")
}
// ─── Semantic Search (Phase 8.4) ────────────────────────
model SearchLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
query String
resultCount Int @default(0)
clickedId String?
createdAt DateTime @default(now())
@@index([userId])
@@map("search_logs")
}
// ─── Annotations (Visual Review) ────────────────────────
enum AnnotationType {
RECTANGLE
ELLIPSE
ARROW
FREEHAND
TEXT
PIN
SCREENSHOT
}
// ─── Annotation ─────────────────────────────────────────
model Annotation {
id String @id @default(cuid())
commentId String
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
revisionId String
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
type AnnotationType
data Json
imageX Float
imageY Float
timestampSeconds Float? // null for image annotations, set for video annotations
frameThumbnailUrl String? // cached frame thumbnail for the annotation's moment
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
feedbackItems FeedbackItem[]
@@index([commentId])
@@index([revisionId])
@@index([revisionId, timestampSeconds])
@@map("annotations")
}
// ─── Approval Type (per pipeline stage) ─────────────────
enum ApprovalType {
NONE
SIMPLE
FORMAL
}
// ─── Feedback Items ────────────────────────────────────
enum FeedbackStatus {
OPEN
IN_PROGRESS
RESOLVED
VERIFIED
REOPENED
}
model FeedbackItem {
id String @id @default(cuid())
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id], onDelete: Cascade)
revisionId String
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
annotationId String?
annotation Annotation? @relation(fields: [annotationId], references: [id], onDelete: SetNull)
commentId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull)
summary String
isActionItem Boolean @default(true)
status FeedbackStatus @default(OPEN)
sortOrder Int @default(0)
assignedToId String?
assignedTo User? @relation("FeedbackAssignee", fields: [assignedToId], references: [id], onDelete: SetNull)
createdById String
createdBy User @relation("FeedbackCreator", fields: [createdById], references: [id])
resolvedById String?
resolvedBy User? @relation("FeedbackResolver", fields: [resolvedById], references: [id], onDelete: SetNull)
resolvedAt DateTime?
resolutionNote String?
verifiedById String?
verifiedBy User? @relation("FeedbackVerifier", fields: [verifiedById], references: [id], onDelete: SetNull)
verifiedAt DateTime?
carriedFromId String?
carriedFrom FeedbackItem? @relation("FeedbackCarry", fields: [carriedFromId], references: [id], onDelete: SetNull)
carriedTo FeedbackItem[] @relation("FeedbackCarry")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([deliverableStageId])
@@index([revisionId])
@@index([assignedToId])
@@index([status])
@@map("feedback_items")
}
// ─── Review Sessions (batch approval) ───────────────────
enum ReviewSessionStatus {
DRAFT
IN_PROGRESS
COMPLETED
}
model ReviewSession {
id String @id @default(cuid())
name String
description String?
status ReviewSessionStatus @default(DRAFT)
createdById String
createdBy User @relation("ReviewSessionCreator", fields: [createdById], references: [id])
organizationId String
items ReviewSessionItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([status])
@@map("review_sessions")
}
model ReviewSessionItem {
id String @id @default(cuid())
sessionId String
session ReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
deliverableStageId String
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id])
revisionId String?
revision Revision? @relation(fields: [revisionId], references: [id])
sortOrder Int
decision String?
decisionNote String?
decidedById String?
decidedBy User? @relation("ReviewSessionDecider", fields: [decidedById], references: [id])
decidedAt DateTime?
@@index([sessionId])
@@map("review_session_items")
}
// ─── External API idempotency ──────────────────────────
//
// Stores the response body of POSTs that carried an `Idempotency-Key`
// header so a retried request returns the same response without
// re-executing. Indexed for fast lookup; createdAt drives the 24-hour
// TTL sweep.
model IdempotencyRecord {
key String
route String
requestHash String
responseBody Json
statusCode Int
createdAt DateTime @default(now())
@@id([key, route])
@@index([createdAt])
@@map("idempotency_records")
}
// ─── Box integration (bidirectional asset transport) ────
//
// Outbound: when a deliverable hits APPROVED, push its latest revision's
// assets into a Box "In/" sub-folder using a strict naming convention
// (omgJobNumber_deliverableSlug_v{round}). Inbound: Box notifies us when
// a new file lands in the watch folder; we match by OMG # + slug and
// attach as a new revision.
//
// Both directions log every attempt. See [[project-box-integration]].
model BoxPushLog {
id String @id @default(cuid())
deliverableId String
deliverable Deliverable @relation(fields: [deliverableId], references: [id], onDelete: Cascade)
revisionId String?
revision Revision? @relation(fields: [revisionId], references: [id], onDelete: SetNull)
boxFolderId String?
status BoxPushStatus @default(PENDING)
attempt Int @default(1)
error String?
sentAt DateTime @default(now())
@@index([deliverableId])
@@index([status])
@@map("box_push_logs")
}
model BoxInboundLog {
id String @id @default(cuid())
boxFileId String
fileName String
matchedDeliverableId String?
matchedProjectId String?
status BoxInboundStatus
error String?
receivedAt DateTime @default(now())
@@index([status])
@@index([receivedAt])
@@map("box_inbound_logs")
}
// ─── Dow: Client Teams (visibility grouping) ───────────
model ClientTeam {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
slug String
createdAt DateTime @default(now())
userMemberships ClientTeamMembership[]
projects Project[]
briefs Brief[]
@@unique([organizationId, slug])
@@index([organizationId])
@@map("client_teams")
}
model ClientTeamMembership {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
clientTeamId String
clientTeam ClientTeam @relation(fields: [clientTeamId], references: [id], onDelete: Cascade)
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
@@id([userId, clientTeamId])
@@index([clientTeamId])
@@map("client_team_memberships")
}
// ─── Dow: Incoming Briefs (pre-project intake) ──────────
//
// A Brief is a request that has arrived but hasn't yet been triaged into
// a Project. Three intake paths all land here: manual create (producer
// types it in), REST API (`POST /api/briefs`), or webhook
// (`POST /api/webhooks/briefs`). Webhook payloads are idempotent on the
// `externalId` column so replays from upstream systems are safe.
//
// Once a brief is approved and matched to work, it gets "promoted" to a
// Project — the status flips to CONVERTED and `convertedProjectId` links
// back to the new Project. That keeps the audit trail without requiring
// the Brief row to disappear.
model Brief {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
title String
description String?
// Source tracking — "manual" | "api" | "webhook:<system>" so we can
// report on where briefs come from without needing a separate enum.
source String @default("manual")
// External idempotency key (from webhook/API). Unique per org so the
// same upstream system can't create duplicates on replay.
externalId String?
// Requestor info — client-side contact. Freeform because intake
// systems can't be relied on to have user rows.
requestorName String?
requestorEmail String?
status BriefStatus @default(PENDING)
priority Priority @default(MEDIUM)
requestedDueDate DateTime?
clientTeamId String?
clientTeam ClientTeam? @relation(fields: [clientTeamId], references: [id])
// When promoted to a Project, fill in convertedProjectId (CONVERTED status).
convertedProjectId String?
convertedProject Project? @relation("BriefConvertedProject", fields: [convertedProjectId], references: [id])
// Pass-through field for anything in the incoming payload we didn't map.
rawPayload Json?
receivedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, externalId])
@@index([organizationId])
@@index([status])
@@index([clientTeamId])
@@map("briefs")
}
// ─── Dow: Production Pods (capacity grouping) ──────────
model Pod {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
slug String
leadUserId String?
leadUser User? @relation("PodLead", fields: [leadUserId], references: [id])
createdAt DateTime @default(now())
members User[] @relation("PodHome")
@@unique([organizationId, slug])
@@index([organizationId])
@@map("pods")
}
// ─── Dow: Resource bookings (daily capacity grid) ──────
// One row per (user, date, jobNumber) — matches the Resources.html
// prototype's model: a producer assigns N hours of a given job to a
// person on a specific day. Multiple rows per (user, date) are expected
// (split days across jobs). jobNumber is a freeform string so it can
// hold Project.omgJobNumber, Project.projectCode, or an ad-hoc scratch
// label — the capacity planner doesn't care what project the hours are
// against, just that the total for the day/week is tracked.
model ResourceBooking {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation("BookingResource", fields: [userId], references: [id], onDelete: Cascade)
date DateTime // date-only — we store 00:00:00 UTC
jobNumber String
hours Float
note String?
createdById String
createdBy User @relation("BookingCreator", fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, date])
@@index([userId, date])
@@map("resource_bookings")
}