dow-prod-tracker/prisma/schema.prisma
DJP bdb133d49a Phase 5: unified versioning + single-asset-per-revision + holding pen
Reshapes the Revision model around how producers + clients actually
work: one upload per round (image OR video, no references), per-stage
version chain V0.1 → V0.2 → V1 → V1.1 → V1.2 → V2 …, holding pen for
inbound files received while a previous version is in client approval,
and per-pipeline filename matching for inbound ingest.

Schema:
  - Revision: drop roundNumber + multi-key attachments. Add major/minor/
    sentToClient/sentAt/asset (single image-or-video object) + unique
    (deliverableStageId, major, minor).
  - PipelineTemplate: add inboundFilenameRegex (per-pipeline matcher).
  - NEW HoldingPenFile model + HoldingPenSource enum (MANUAL/API/BOX).
  - Empty prod DB → clean ALTER TABLE migration, no backfill.

Send-to-client semantics:
  - The latest internal revision IS the V{n} — promote in place
    (major+=1, minor=0, sentToClient=true). Chain reads V0.1, V0.2, V1,
    V1.1, V1.2, V2, ...
  - sendToClient is the ONLY Box-push trigger. Auto-on-APPROVED removed
    from deliverable-status-service. APPROVED is now an internal "done
    iterating" state, separate from "shipped to client".

Three input channels, one matcher:
  - NEW src/lib/services/inbound-ingest-service.ts — consolidates Box
    webhook, /api/v1/upload, and manual upload-by-filename. One regex
    resolver, one project/deliverable matcher, one routing + notification
    fan-out.
  - box-inbound-service is now a thin wrapper that fetches Box metadata
    and delegates.
  - external-delivery-service.parseInboundFileName takes an optional
    regex override. Default: ^(\d+)_([a-z0-9-]+)(?:_v(\d+))?(?:\.[a-z0-9]+)?$
    captures (1) OMG #, (2) slug, (3) optional version.
  - buildDeliveryNaming now uses {omg}_{slug}_V{major} for Box folders.

Holding pen:
  - When a deliverable is IN_REVIEW (a V{n} is awaiting client decision)
    and a new file arrives via any channel, it lands in HoldingPenFile —
    NOT in the active chain. Producer manually promotes (creates the
    next minor on the chosen stage) or discards.
  - Held files render in a new HoldingPenPanel on the deliverable detail
    page when present. Source pill (MANUAL/API/BOX), parsed identifiers,
    target-stage picker, Promote + Discard buttons.

Per-pipeline regex UI:
  - NEW InboundMatchingRules section in the pipeline editor. Live regex
    compile, sample-filename match test with echoed captures, save with
    server-side regex-compile validation.

Upload simplification:
  - storeRevisionAsset replaces the old processAndStoreImage +
    processAndStoreVideo + multi-key attachment merge. MIME-detects kind,
    preserves PNG alpha flatten + TIFF→PNG + thumbnail for images, and
    keeps the async HLS transcode pipeline for videos.
  - The single revision upload route drops the `type=` parameter.
  - Three legacy components deleted: image-gallery, image-upload-zone,
    video-upload-zone. NEW asset-upload-zone (unified drop zone).

New API:
  - POST /api/stages/:stageId/revisions/:revisionId/send-to-client
  - GET  /api/deliverables/:id/holding-pen
  - POST /api/deliverables/:id/holding-pen/:fileId (promote)
  - DELETE /api/deliverables/:id/holding-pen/:fileId (discard)
  - POST /api/v1/upload (multipart; same matcher as Box webhook)

UI label rollup:
  - src/lib/format-revision-label.ts is the single source of truth.
    Sent revisions render `V{major}`; internal `V{major}.{minor}`.
  - Revision node/timeline, session presenter/builder/summary, revision
    list, stage review panel — all read the helper.
  - Comparison toolbar simplified to cross-revision picker (no more
    reference-vs-current within a single revision).

The deliverable annotation review page is a temporary stub (links back
to the deliverable detail page where the in-row controls live). The
full annotation overlay + comparison surface will be rebuilt against
the single-asset model in a follow-up.

Run on next deploy:
  docker compose -p loreal-prod-tracker exec app npx prisma migrate deploy

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

1251 lines
40 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
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])
@@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")
}