feat: Implement automation event bus and rule engine

- Add event bus for dispatching automation events with handlers.
- Create rule engine to evaluate events against defined triggers.
- Introduce chat provider to interface with Claude API and Ollama fallback.
- Define tool schemas for Claude-compatible operations.
- Implement tool executor to map tool calls to service layer functions.
- Develop automation service for CRUD operations on rules and event handling.
This commit is contained in:
Leivur Djurhuus 2026-03-12 11:20:21 -05:00
parent a4b5bbf5c9
commit e5b398d7da
32 changed files with 4080 additions and 132 deletions

View file

@ -12,9 +12,14 @@ AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=""
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Ollama (AI semantic search — Phase 8.4)
# Local Ollama instance for embeddings and LLM summarization.
# Claude API (AI Chat Assistant — primary provider)
# Used for the chat interface. Falls back to Ollama if unavailable.
# Get your key at: https://console.anthropic.com/
ANTHROPIC_API_KEY=""
# Ollama (AI — embeddings, search, chat fallback)
# Local Ollama instance for embeddings, LLM summarization, and chat fallback.
# No data leaves the network. Zero ongoing AI costs.
OLLAMA_HOST="http://localhost:11434"
OLLAMA_EMBED_MODEL="nomic-embed-text"
OLLAMA_LLM_MODEL="qwen3.5:9b"
OLLAMA_LLM_MODEL="qwen3:1.7b"

126
CLI_READINESS_STATUS.md Normal file
View file

@ -0,0 +1,126 @@
# CLI Anything — Readiness & Implementation Status
> Tracks all prerequisite work and CLI Anything implementation progress.
> Updated as each item is completed.
---
## Phase A: Service Layer Gaps (Prerequisites)
| # | Item | Status | Date |
|---|------|--------|------|
| A1 | `getBlockedStages()` — query all blocked stages with dependency info | ✅ Complete | 2026-03-12 |
| A2 | `getAvailableArtists()` — filter by capacity + skills org-wide | ✅ Complete | 2026-03-12 |
| A3 | `listOverdue()` — flat overdue list with assignee info for AI consumption | ✅ Complete | 2026-03-12 |
| A4 | Bulk `createDeliverables()` — batch creation with stage auto-setup | ✅ Complete | 2026-03-12 |
| A5 | Bulk `updateStageStatuses()` — batch status updates with validation | ✅ Complete | 2026-03-12 |
| A6 | Bulk `assignArtists()` — batch assignments with validation | ✅ Complete | 2026-03-12 |
## Phase B: Dry-Run / Preview Mode for Mutations
| # | Item | Status | Date |
|---|------|--------|------|
| B1 | `dryRun` option on `updateStageStatus()` — shows downstream unblock preview | ✅ Complete | 2026-03-12 |
| B2 | `dryRun` option on `assignUserToStage()` — shows action preview | ✅ Complete | 2026-03-12 |
| B3 | `dryRun` option on `createProject()` / `updateProject()` / `updateDeliverable()` | ✅ Complete | 2026-03-12 |
## Phase C: Skills & Capacity Data Population
| # | Item | Status | Date |
|---|------|--------|------|
| C1 | Seed default CG pipeline skills (10 skills) | ✅ Complete | Pre-existing in seed.ts |
| C2 | Seed stage-skill requirement mappings (all 10 stages) | ✅ Complete | Pre-existing in seed.ts |
| C3 | Skills admin UI functional (registry + user assignment) | ✅ Complete | Pre-existing |
## Phase D: Event Bus & Automation Infrastructure (UPGRADE_PLAN 7.1 foundation)
| # | Item | Status | Date |
|---|------|--------|------|
| D1 | `AutomationRule` + `AutomationExecution` + `ChatMessage` Prisma models | ✅ Complete | 2026-03-12 |
| D2 | Event bus — typed events for stage/revision/assignment changes | ✅ Complete | 2026-03-12 |
| D3 | Rule evaluation engine — condition matching with 9 operators | ✅ Complete | 2026-03-12 |
| D4 | Action executor — status update, notify, assign (incl. auto), webhook | ✅ Complete | 2026-03-12 |
| D5 | Execution logging for audit trail (SUCCESS/PARTIAL_FAILURE/FAILURE) | ✅ Complete | 2026-03-12 |
| D6 | API routes: `/api/automations/`, `[ruleId]/`, `[ruleId]/executions/` | ✅ Complete | 2026-03-12 |
## Phase E: Docker & Ollama Readiness (UPGRADE_PLAN 12.112.2)
| # | Item | Status | Date |
|---|------|--------|------|
| E1 | Ollama entrypoint script with auto model pull (nomic-embed-text + qwen3) | ✅ Complete | 2026-03-12 |
| E2 | Health checks + startup orchestration (db → ollama → app) | ✅ Complete | 2026-03-12 |
| E3 | App service finalized in docker-compose with production profile | ✅ Complete | 2026-03-12 |
## Phase F: CLI Anything Implementation
| # | Item | Status | Date |
|---|------|--------|------|
| F1 | Chat UI component — slide-out panel with message bubbles + suggestions | ✅ Complete | 2026-03-12 |
| F2 | Chat history Prisma model (`ChatMessage`) | ✅ Complete | 2026-03-12 |
| F3 | `/api/chat` route — Claude tool-use loop with max 5 iterations | ✅ Complete | 2026-03-12 |
| F4 | 20 tool definitions mapped from Zod validators + service layer | ✅ Complete | 2026-03-12 |
| F5 | Tool execution handlers — all 20 tools wired to service functions | ✅ Complete | 2026-03-12 |
| F6 | Confirmation flow — system prompt enforces dryRun before mutations | ✅ Complete | 2026-03-12 |
| F7 | TanStack Query cache invalidation from chat mutations | ✅ Complete | 2026-03-12 |
| F8 | Ollama fallback provider with auto-failover | ✅ Complete | 2026-03-12 |
| F9 | Chat panel wired into app topbar + provider health indicator | ✅ Complete | 2026-03-12 |
---
## Summary
| Phase | Items | Completed | Status |
|-------|-------|-----------|--------|
| A — Service Layer Gaps | 6 | 6 | ✅ Done |
| B — Dry-Run Preview | 3 | 3 | ✅ Done |
| C — Skills Data | 3 | 3 | ✅ Done |
| D — Automation Engine | 6 | 6 | ✅ Done |
| E — Docker/Ollama | 3 | 3 | ✅ Done |
| F — CLI Anything | 9 | 9 | ✅ Done |
| **Total** | **30** | **30** | **✅ All Complete** |
## Files Created / Modified
### New Files (18)
- `src/lib/automation/event-bus.ts` — Event dispatch system
- `src/lib/automation/rule-engine.ts` — Rule matching with 9 operators
- `src/lib/automation/action-executor.ts` — 4 action types (status, notify, assign, webhook)
- `src/lib/services/automation-service.ts` — Rule CRUD + event handler registration
- `src/lib/chat/tool-definitions.ts` — 20 tool schemas for Claude API
- `src/lib/chat/tool-executor.ts` — Maps tools to service layer calls
- `src/lib/chat/provider.ts` — Claude + Ollama fallback abstraction
- `src/app/api/automations/route.ts` — GET/POST automation rules
- `src/app/api/automations/[ruleId]/route.ts` — GET/PATCH/DELETE rule
- `src/app/api/automations/[ruleId]/executions/route.ts` — GET execution log
- `src/app/api/chat/route.ts` — POST chat + GET provider status
- `src/hooks/use-chat.ts` — React hook for chat state + cache invalidation
- `src/components/chat/chat-panel.tsx` — Slide-out chat UI
- `docker/ollama-entrypoint.sh` — Auto-pull models on first run
- `docker/db-init.sql` — pgvector extension initialization
### Modified Files (10)
- `src/lib/services/stage-service.ts` — Added `getBlockedStages()`, `bulkUpdateStageStatuses()`, dryRun on `updateStageStatus()`
- `src/lib/services/skill-service.ts` — Added `getAvailableArtists()`
- `src/lib/services/deadline-service.ts` — Added `listOverdue()`
- `src/lib/services/deliverable-service.ts` — Added `bulkCreateDeliverables()`, dryRun on `updateDeliverable()`
- `src/lib/services/assignment-service.ts` — Added `bulkAssignArtists()`, dryRun on `assignUserToStage()`
- `src/lib/services/project-service.ts` — Added dryRun on `createProject()` and `updateProject()`
- `src/components/layout/topbar.tsx` — Integrated ChatPanel
- `prisma/schema.prisma` — Added AutomationRule, AutomationExecution, ChatMessage models
- `docker-compose.yml` — Full production stack with health checks
- `.env.example` — Added ANTHROPIC_API_KEY
---
## Next Steps (Post-Implementation)
1. **Run `npx prisma generate`** to regenerate the Prisma client with new models
2. **Run `npx prisma migrate dev`** to create the database migration
3. **Add `ANTHROPIC_API_KEY`** to your `.env` file
4. **Test the chat interface** — click the chat icon in the topbar
5. **Create automation rules** via the `/api/automations` endpoint
6. **Wire event emissions** into existing stage/revision/assignment services for full automation
---
*Last updated: 2026-03-12*

55
assets/temp/Artists-Roles Normal file
View file

@ -0,0 +1,55 @@
Full_Name
Role
Aditya Varma
CGI Stills Artist Senior
Ameya Bhagwat
CGI Artist (Stills) senior
Ameya Kandivkar
CGI Artist (Animation)
Amit Sharma
CGI Artist (Stills) INTERMEDIATE
Anantha Krishnan
CGI Model Prep Team Lead
Ankit Kumar
CGI Artist (Stills) senior
Ankit Kumar Gupta
CGI Animation Artist
Arun Prakash
Model Prep Artist
Babon Ghosh
CGI Animation Artist
Bharat Bhushan
CGI Stills Artist senior
Eric Rodriguez
CGI Artist stills team lead
Hujef Bagwan
CGI Animation Artist
Ishan Aneja
CGI Artist (Stills) INTERMEDIATE
Jinesh Thacker
CGI Stills Artist Senior
Juan Garcia
CGI Artist (Stills) Senior
Krishna Nand
CGI Artist still entry
Nijil Rajithan
Model Prep Artist
Niteen Veer
CGI Artist (Animation)
Nizam P
CGI Stills Artist INTERMEDIATE
Pankaj Duragkar
CGI Artist (Animation)
Prateek Kaushik
CGI Stills Team Lead
Sandeep Sidhu
CGI Animation Team Lead
Soham Baviskar
CGI model prep Entry
Sonu Kumar
CGI Animation Artist
Xavier Plasso
CGI Stills Artist senior
Yash Vaidya
CGI Artist (Stills) Entry

View file

@ -11,36 +11,61 @@ services:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/db-init.sql:/docker-entrypoint-initdb.d/01-pgvector.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# ─── Ollama (local AI embeddings) ──────────────────────
# ─── Ollama (local AI — embeddings + chat fallback) ────
ollama:
image: ollama/ollama:latest
restart: unless-stopped
entrypoint: ["/bin/bash", "/entrypoint.sh"]
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
- ./docker/ollama-entrypoint.sh:/entrypoint.sh:ro
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:11434/api/tags || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
# Uncomment for GPU acceleration (requires nvidia-container-toolkit):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
# ─── Next.js app (production only) ─────────────────────
# Uncomment for deployment. For local dev, run `npm run dev` directly.
# app:
# build: .
# restart: unless-stopped
# ports:
# - "3000:3000"
# environment:
# DATABASE_URL: postgresql://postgres:postgres@db:5432/hp_prod_tracker?schema=public
# OLLAMA_HOST: http://ollama:11434
# OLLAMA_EMBED_MODEL: nomic-embed-text
# NODE_ENV: production
# depends_on:
# db:
# condition: service_healthy
# ─── Next.js app (production) ──────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/hp_prod_tracker?schema=public
OLLAMA_HOST: http://ollama:11434
OLLAMA_EMBED_MODEL: nomic-embed-text
OLLAMA_LLM_MODEL: qwen3:1.7b
NODE_ENV: production
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production}
depends_on:
db:
condition: service_healthy
ollama:
condition: service_healthy
profiles:
- production
volumes:
pgdata:

2
docker/db-init.sql Normal file
View file

@ -0,0 +1,2 @@
-- Ensure pgvector extension is enabled on database creation
CREATE EXTENSION IF NOT EXISTS vector;

View file

@ -0,0 +1,39 @@
#!/bin/bash
# Ollama entrypoint — starts the server and pulls required models on first run.
# Models are cached in the Docker volume, so subsequent starts skip the pull.
set -e
echo "Starting Ollama server..."
ollama serve &
OLLAMA_PID=$!
# Wait for server to be ready
echo "Waiting for Ollama to be ready..."
for i in $(seq 1 30); do
if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
echo "Ollama is ready."
break
fi
sleep 1
done
# Pull required models if not already present
pull_if_missing() {
local model=$1
if ollama list 2>/dev/null | grep -q "$model"; then
echo "Model '$model' already available."
else
echo "Pulling model '$model'... (this may take a few minutes on first run)"
ollama pull "$model"
echo "Model '$model' pulled successfully."
fi
}
pull_if_missing "nomic-embed-text"
pull_if_missing "qwen3:1.7b"
echo "All models ready."
# Keep the server running
wait $OLLAMA_PID

View file

@ -1,28 +0,0 @@
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Add embedding columns to projects and deliverables
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "embedding" vector(768);
ALTER TABLE "deliverables" ADD COLUMN IF NOT EXISTS "embedding" vector(768);
-- Create indexes for fast cosine similarity search
CREATE INDEX IF NOT EXISTS "projects_embedding_idx" ON "projects" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
CREATE INDEX IF NOT EXISTS "deliverables_embedding_idx" ON "deliverables" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
-- Create search_logs table
CREATE TABLE IF NOT EXISTS "search_logs" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"query" TEXT NOT NULL,
"resultCount" INTEGER NOT NULL DEFAULT 0,
"clickedId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "search_logs_pkey" PRIMARY KEY ("id")
);
-- Index for search_logs by user
CREATE INDEX IF NOT EXISTS "search_logs_userId_idx" ON "search_logs" ("userId");
-- Foreign key for search_logs
ALTER TABLE "search_logs" ADD CONSTRAINT "search_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,493 @@
-- Enable pgvector extension (must be before any vector column usage)
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'PRODUCER', 'ARTIST');
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('ACTIVE', 'ON_HOLD', 'COMPLETED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "Priority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
-- CreateEnum
CREATE TYPE "DeliverableStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'IN_REVIEW', 'APPROVED', 'ON_HOLD');
-- CreateEnum
CREATE TYPE "StageStatus" AS ENUM ('BLOCKED', 'NOT_STARTED', 'IN_PROGRESS', 'IN_REVIEW', 'CHANGES_REQUESTED', 'APPROVED', 'DELIVERED', 'SKIPPED');
-- CreateEnum
CREATE TYPE "RevisionStatus" AS ENUM ('SUBMITTED', 'IN_REVIEW', 'CHANGES_REQUESTED', 'APPROVED');
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('ASSIGNMENT', 'STATUS_CHANGE', 'REVISION_SUBMITTED', 'REVISION_FEEDBACK', 'COMMENT', 'DEADLINE_APPROACHING', 'DEADLINE_OVERDUE', 'STAGE_UNBLOCKED');
-- CreateEnum
CREATE TYPE "AssignmentRole" AS ENUM ('LEAD', 'SUPPORT');
-- CreateEnum
CREATE TYPE "SkillLevel" AS ENUM ('JUNIOR', 'INTERMEDIATE', 'SENIOR', 'LEAD');
-- CreateEnum
CREATE TYPE "ExecutionStatus" AS ENUM ('SUCCESS', 'PARTIAL_FAILURE', 'FAILURE');
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"role" "Role" NOT NULL DEFAULT 'ARTIST',
"department" TEXT,
"maxCapacity" INTEGER NOT NULL DEFAULT 5,
"organizationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification_tokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "pipeline_stage_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"isCriticalGate" BOOLEAN NOT NULL DEFAULT false,
"isOptional" BOOLEAN NOT NULL DEFAULT false,
"description" TEXT,
"estimatedDays" DOUBLE PRECISION,
CONSTRAINT "pipeline_stage_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pipeline_stage_dependencies" (
"id" TEXT NOT NULL,
"stageId" TEXT NOT NULL,
"prerequisiteId" TEXT NOT NULL,
CONSTRAINT "pipeline_stage_dependencies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" TEXT NOT NULL,
"projectCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'ACTIVE',
"priority" "Priority" NOT NULL DEFAULT 'MEDIUM',
"startDate" TIMESTAMP(3),
"dueDate" TIMESTAMP(3),
"businessUnit" TEXT,
"formFactor" TEXT,
"codeName" TEXT,
"npiOrRefresh" TEXT,
"quarter" TEXT,
"requestor" TEXT,
"workfrontId" TEXT,
"omgCode" TEXT,
"bmtId" TEXT,
"estimatedCost" DOUBLE PRECISION,
"actualCost" DOUBLE PRECISION,
"agency" TEXT,
"embedding" vector(768),
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deliverables" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" "DeliverableStatus" NOT NULL DEFAULT 'NOT_STARTED',
"priority" "Priority" NOT NULL DEFAULT 'MEDIUM',
"dueDate" TIMESTAMP(3),
"notes" TEXT,
"cmfSku" TEXT,
"assetCount" INTEGER,
"requestedDueDate" TIMESTAMP(3),
"plannedDeliveryDate" TIMESTAMP(3),
"actualDeliveryDate" TIMESTAMP(3),
"wfInputDate" TIMESTAMP(3),
"embedding" vector(768),
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "deliverables_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deliverable_stages" (
"id" TEXT NOT NULL,
"status" "StageStatus" NOT NULL DEFAULT 'BLOCKED',
"revisionRound" INTEGER NOT NULL DEFAULT 0,
"startDate" TIMESTAMP(3),
"completedDate" TIMESTAMP(3),
"dueDate" TIMESTAMP(3),
"notes" TEXT,
"subStatus" TEXT,
"deliverableId" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "deliverable_stages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stage_assignments" (
"id" TEXT NOT NULL,
"role" "AssignmentRole" DEFAULT 'LEAD',
"deliverableStageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stage_assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "revisions" (
"id" TEXT NOT NULL,
"roundNumber" INTEGER NOT NULL,
"status" "RevisionStatus" NOT NULL DEFAULT 'SUBMITTED',
"feedbackNotes" TEXT,
"internalNotes" TEXT,
"attachments" JSONB,
"deliverableStageId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "revisions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"deliverableStageId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"parentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"link" TEXT,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "skills" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "skills_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_skills" (
"userId" TEXT NOT NULL,
"skillId" TEXT NOT NULL,
"level" "SkillLevel" NOT NULL DEFAULT 'INTERMEDIATE',
CONSTRAINT "user_skills_pkey" PRIMARY KEY ("userId","skillId")
);
-- CreateTable
CREATE TABLE "stage_skill_requirements" (
"stageTemplateId" TEXT NOT NULL,
"skillId" TEXT NOT NULL,
"importance" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "stage_skill_requirements_pkey" PRIMARY KEY ("stageTemplateId","skillId")
);
-- CreateTable
CREATE TABLE "automation_rules" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"organizationId" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"trigger" JSONB NOT NULL,
"actions" JSONB NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "automation_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "automation_executions" (
"id" TEXT NOT NULL,
"ruleId" TEXT NOT NULL,
"triggeredBy" JSONB NOT NULL,
"result" JSONB NOT NULL,
"status" "ExecutionStatus" NOT NULL,
"error" TEXT,
"executedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "automation_executions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chat_messages" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"toolCalls" JSONB,
"toolResults" JSONB,
"metadata" JSONB,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "search_logs" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"query" TEXT NOT NULL,
"resultCount" INTEGER NOT NULL DEFAULT 0,
"clickedId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "search_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_domain_key" ON "organizations"("domain");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_providerAccountId_key" ON "accounts"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_sessionToken_key" ON "sessions"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "pipeline_stage_templates_name_key" ON "pipeline_stage_templates"("name");
-- CreateIndex
CREATE UNIQUE INDEX "pipeline_stage_templates_slug_key" ON "pipeline_stage_templates"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "pipeline_stage_templates_order_key" ON "pipeline_stage_templates"("order");
-- CreateIndex
CREATE UNIQUE INDEX "pipeline_stage_dependencies_stageId_prerequisiteId_key" ON "pipeline_stage_dependencies"("stageId", "prerequisiteId");
-- CreateIndex
CREATE UNIQUE INDEX "projects_projectCode_key" ON "projects"("projectCode");
-- CreateIndex
CREATE INDEX "projects_organizationId_idx" ON "projects"("organizationId");
-- CreateIndex
CREATE INDEX "projects_status_idx" ON "projects"("status");
-- CreateIndex
CREATE INDEX "deliverables_projectId_idx" ON "deliverables"("projectId");
-- CreateIndex
CREATE INDEX "deliverables_status_idx" ON "deliverables"("status");
-- CreateIndex
CREATE INDEX "deliverable_stages_deliverableId_idx" ON "deliverable_stages"("deliverableId");
-- CreateIndex
CREATE INDEX "deliverable_stages_status_idx" ON "deliverable_stages"("status");
-- CreateIndex
CREATE UNIQUE INDEX "deliverable_stages_deliverableId_templateId_key" ON "deliverable_stages"("deliverableId", "templateId");
-- CreateIndex
CREATE INDEX "stage_assignments_userId_idx" ON "stage_assignments"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "stage_assignments_deliverableStageId_userId_key" ON "stage_assignments"("deliverableStageId", "userId");
-- CreateIndex
CREATE INDEX "revisions_deliverableStageId_idx" ON "revisions"("deliverableStageId");
-- CreateIndex
CREATE INDEX "comments_deliverableStageId_idx" ON "comments"("deliverableStageId");
-- CreateIndex
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
-- CreateIndex
CREATE INDEX "notifications_userId_isRead_idx" ON "notifications"("userId", "isRead");
-- CreateIndex
CREATE UNIQUE INDEX "skills_name_key" ON "skills"("name");
-- CreateIndex
CREATE INDEX "automation_rules_organizationId_idx" ON "automation_rules"("organizationId");
-- CreateIndex
CREATE INDEX "automation_rules_isEnabled_idx" ON "automation_rules"("isEnabled");
-- CreateIndex
CREATE INDEX "automation_executions_ruleId_idx" ON "automation_executions"("ruleId");
-- CreateIndex
CREATE INDEX "automation_executions_executedAt_idx" ON "automation_executions"("executedAt");
-- CreateIndex
CREATE INDEX "chat_messages_sessionId_idx" ON "chat_messages"("sessionId");
-- CreateIndex
CREATE INDEX "chat_messages_userId_idx" ON "chat_messages"("userId");
-- CreateIndex
CREATE INDEX "search_logs_userId_idx" ON "search_logs"("userId");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pipeline_stage_dependencies" ADD CONSTRAINT "pipeline_stage_dependencies_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "pipeline_stage_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pipeline_stage_dependencies" ADD CONSTRAINT "pipeline_stage_dependencies_prerequisiteId_fkey" FOREIGN KEY ("prerequisiteId") REFERENCES "pipeline_stage_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deliverable_stages" ADD CONSTRAINT "deliverable_stages_deliverableId_fkey" FOREIGN KEY ("deliverableId") REFERENCES "deliverables"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deliverable_stages" ADD CONSTRAINT "deliverable_stages_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "pipeline_stage_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stage_assignments" ADD CONSTRAINT "stage_assignments_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stage_assignments" ADD CONSTRAINT "stage_assignments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "revisions" ADD CONSTRAINT "revisions_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_skills" ADD CONSTRAINT "user_skills_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_skills" ADD CONSTRAINT "user_skills_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "skills"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stage_skill_requirements" ADD CONSTRAINT "stage_skill_requirements_stageTemplateId_fkey" FOREIGN KEY ("stageTemplateId") REFERENCES "pipeline_stage_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stage_skill_requirements" ADD CONSTRAINT "stage_skill_requirements_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "skills"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_rules" ADD CONSTRAINT "automation_rules_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_rules" ADD CONSTRAINT "automation_rules_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_executions" ADD CONSTRAINT "automation_executions_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "automation_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "search_logs" ADD CONSTRAINT "search_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -89,6 +89,7 @@ model Organization {
users User[]
projects Project[]
automationRules AutomationRule[]
@@map("organizations")
}
@ -118,6 +119,8 @@ model User {
notifications Notification[]
skills UserSkill[]
searchLogs SearchLog[]
automationRules AutomationRule[] @relation("AutomationCreator")
chatMessages ChatMessage[]
@@map("users")
}
@ -415,6 +418,69 @@ model StageSkillRequirement {
@@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
}
// ─── 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")
}
// ─── Semantic Search (Phase 8.4) ────────────────────────
model SearchLog {

View file

@ -182,18 +182,38 @@ async function main() {
department: string;
maxCapacity: number;
}[] = [
// Producers
{ id: "user-producer-001", name: "Sarah Chen", email: "sarah.chen@oliver.agency", role: "PRODUCER", department: "CG Production", maxCapacity: 8 },
{ id: "user-producer-002", name: "Marcus Johnson", email: "marcus.johnson@oliver.agency", role: "PRODUCER", department: "CG Production", maxCapacity: 8 },
{ id: "user-artist-001", name: "Alex Rivera", email: "alex.rivera@oliver.agency", role: "ARTIST", department: "3D Modeling", maxCapacity: 5 },
{ id: "user-artist-002", name: "Priya Patel", email: "priya.patel@oliver.agency", role: "ARTIST", department: "3D Modeling", maxCapacity: 5 },
{ id: "user-artist-003", name: "James O'Brien", email: "james.obrien@oliver.agency", role: "ARTIST", department: "Lighting & Rendering", maxCapacity: 6 },
{ id: "user-artist-004", name: "Yuki Tanaka", email: "yuki.tanaka@oliver.agency", role: "ARTIST", department: "Lighting & Rendering", maxCapacity: 5 },
{ id: "user-artist-005", name: "Elena Volkov", email: "elena.volkov@oliver.agency", role: "ARTIST", department: "Compositing", maxCapacity: 5 },
{ id: "user-artist-006", name: "David Kim", email: "david.kim@oliver.agency", role: "ARTIST", department: "Compositing", maxCapacity: 6 },
{ id: "user-artist-007", name: "Aisha Mohammed", email: "aisha.mohammed@oliver.agency", role: "ARTIST", department: "Animation", maxCapacity: 4 },
{ id: "user-artist-008", name: "Carlos Mendez", email: "carlos.mendez@oliver.agency", role: "ARTIST", department: "Animation", maxCapacity: 4 },
{ id: "user-artist-009", name: "Sophie Laurent", email: "sophie.laurent@oliver.agency", role: "ARTIST", department: "Retouching", maxCapacity: 7 },
{ id: "user-artist-010", name: "Ryan Cooper", email: "ryan.cooper@oliver.agency", role: "ARTIST", department: "3D Generalist", maxCapacity: 5 },
// CGI Stills Artists
{ id: "user-artist-001", name: "Aditya Varma", email: "aditya.varma@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-002", name: "Ameya Bhagwat", email: "ameya.bhagwat@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-004", name: "Amit Sharma", email: "amit.sharma@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 5 },
{ id: "user-artist-006", name: "Ankit Kumar", email: "ankit.kumar@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-010", name: "Bharat Bhushan", email: "bharat.bhushan@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-011", name: "Eric Rodriguez", email: "eric.rodriguez@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 8 },
{ id: "user-artist-013", name: "Ishan Aneja", email: "ishan.aneja@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 5 },
{ id: "user-artist-014", name: "Jinesh Thacker", email: "jinesh.thacker@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-015", name: "Juan Garcia", email: "juan.garcia@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-016", name: "Krishna Nand", email: "krishna.nand@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 4 },
{ id: "user-artist-019", name: "Nizam P", email: "nizam.p@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 5 },
{ id: "user-artist-021", name: "Prateek Kaushik", email: "prateek.kaushik@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 8 },
{ id: "user-artist-025", name: "Xavier Plasso", email: "xavier.plasso@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 6 },
{ id: "user-artist-026", name: "Yash Vaidya", email: "yash.vaidya@oliver.agency", role: "ARTIST", department: "CGI Stills", maxCapacity: 4 },
// CGI Animation Artists
{ id: "user-artist-003", name: "Ameya Kandivkar", email: "ameya.kandivkar@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
{ id: "user-artist-007", name: "Ankit Kumar Gupta", email: "ankit.gupta@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
{ id: "user-artist-009", name: "Babon Ghosh", email: "babon.ghosh@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
{ id: "user-artist-012", name: "Hujef Bagwan", email: "hujef.bagwan@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
{ id: "user-artist-018", name: "Niteen Veer", email: "niteen.veer@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
{ id: "user-artist-020", name: "Pankaj Duragkar", email: "pankaj.duragkar@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
{ id: "user-artist-022", name: "Sandeep Sidhu", email: "sandeep.sidhu@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 8 },
{ id: "user-artist-024", name: "Sonu Kumar", email: "sonu.kumar@oliver.agency", role: "ARTIST", department: "CGI Animation", maxCapacity: 5 },
// Model Prep Artists
{ id: "user-artist-005", name: "Anantha Krishnan", email: "anantha.krishnan@oliver.agency", role: "ARTIST", department: "Model Prep", maxCapacity: 8 },
{ id: "user-artist-008", name: "Arun Prakash", email: "arun.prakash@oliver.agency", role: "ARTIST", department: "Model Prep", maxCapacity: 5 },
{ id: "user-artist-017", name: "Nijil Rajithan", email: "nijil.rajithan@oliver.agency", role: "ARTIST", department: "Model Prep", maxCapacity: 5 },
{ id: "user-artist-023", name: "Soham Baviskar", email: "soham.baviskar@oliver.agency", role: "ARTIST", department: "Model Prep", maxCapacity: 4 },
];
for (const member of TEAM_MEMBERS) {
@ -220,15 +240,15 @@ async function main() {
const SKILLS = [
"Modeling",
"Texturing",
"UV Mapping",
"Texturing",
"Lighting",
"Rendering",
"Compositing",
"Retouching",
"Photography",
"Animation",
"Rigging",
"Photography",
"Retouching",
];
const skillMap = new Map<string, string>();
@ -348,26 +368,61 @@ async function main() {
// [userId, [skillName, level]]
const USER_SKILLS: [string, [string, "JUNIOR" | "INTERMEDIATE" | "SENIOR" | "LEAD"][]][] = [
// Alex Rivera — 3D Modeling specialist
["user-artist-001", [["Modeling", "SENIOR"], ["UV Mapping", "SENIOR"], ["Texturing", "INTERMEDIATE"], ["Rigging", "JUNIOR"]]],
// Priya Patel — 3D Modeling specialist
["user-artist-002", [["Modeling", "LEAD"], ["UV Mapping", "LEAD"], ["Texturing", "SENIOR"], ["Rigging", "INTERMEDIATE"]]],
// James O'Brien — Lighting & Rendering
["user-artist-003", [["Lighting", "LEAD"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Photography", "JUNIOR"]]],
// Yuki Tanaka — Lighting & Rendering
["user-artist-004", [["Lighting", "SENIOR"], ["Rendering", "LEAD"], ["Compositing", "INTERMEDIATE"], ["Texturing", "JUNIOR"]]],
// Elena Volkov — Compositing
["user-artist-005", [["Compositing", "LEAD"], ["Retouching", "SENIOR"], ["Lighting", "INTERMEDIATE"], ["Photography", "INTERMEDIATE"]]],
// David Kim — Compositing
["user-artist-006", [["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"], ["Lighting", "JUNIOR"], ["Rendering", "INTERMEDIATE"]]],
// Aisha Mohammed — Animation
["user-artist-007", [["Animation", "LEAD"], ["Rigging", "SENIOR"], ["Rendering", "INTERMEDIATE"], ["Lighting", "JUNIOR"]]],
// Carlos Mendez — Animation
["user-artist-008", [["Animation", "SENIOR"], ["Rigging", "LEAD"], ["Rendering", "INTERMEDIATE"], ["Modeling", "JUNIOR"]]],
// Sophie Laurent — Retouching
["user-artist-009", [["Retouching", "LEAD"], ["Photography", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Lighting", "JUNIOR"]]],
// Ryan Cooper — 3D Generalist
["user-artist-010", [["Modeling", "INTERMEDIATE"], ["Texturing", "INTERMEDIATE"], ["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"], ["Animation", "JUNIOR"]]],
// ── CGI Stills ────────────────────────────────────────
// Aditya Varma — Senior, Photocomps specialty
["user-artist-001", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"], ["Photography", "INTERMEDIATE"]]],
// Ameya Bhagwat — Senior
["user-artist-002", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
// Amit Sharma — Intermediate
["user-artist-004", [["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"]]],
// Ankit Kumar — Senior
["user-artist-006", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
// Bharat Bhushan — Senior
["user-artist-010", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
// Eric Rodriguez — Stills Team Lead
["user-artist-011", [["Lighting", "LEAD"], ["Rendering", "SENIOR"], ["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"]]],
// Ishan Aneja — Intermediate
["user-artist-013", [["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"]]],
// Jinesh Thacker — Senior
["user-artist-014", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
// Juan Garcia — Senior
["user-artist-015", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
// Krishna Nand — Entry/Junior
["user-artist-016", [["Lighting", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Nizam P — Intermediate
["user-artist-019", [["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"]]],
// Prateek Kaushik — Stills Team Lead
["user-artist-021", [["Lighting", "LEAD"], ["Rendering", "SENIOR"], ["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"]]],
// Xavier Plasso — Senior
["user-artist-025", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
// Yash Vaidya — Entry/Junior
["user-artist-026", [["Lighting", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// ── CGI Animation ─────────────────────────────────────
// Ameya Kandivkar — Intermediate
["user-artist-003", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Ankit Kumar Gupta — Intermediate
["user-artist-007", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Babon Ghosh — Intermediate
["user-artist-009", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Hujef Bagwan — Intermediate
["user-artist-012", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Niteen Veer — Intermediate
["user-artist-018", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Pankaj Duragkar — Intermediate
["user-artist-020", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// Sandeep Sidhu — Animation Team Lead
["user-artist-022", [["Animation", "LEAD"], ["Rigging", "SENIOR"], ["Rendering", "INTERMEDIATE"]]],
// Sonu Kumar — Intermediate
["user-artist-024", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
// ── Model Prep ────────────────────────────────────────
// Anantha Krishnan — Model Prep Team Lead
["user-artist-005", [["Modeling", "LEAD"], ["UV Mapping", "LEAD"], ["Texturing", "SENIOR"]]],
// Arun Prakash — Intermediate
["user-artist-008", [["Modeling", "INTERMEDIATE"], ["UV Mapping", "INTERMEDIATE"], ["Texturing", "JUNIOR"]]],
// Nijil Rajithan — Intermediate
["user-artist-017", [["Modeling", "INTERMEDIATE"], ["UV Mapping", "INTERMEDIATE"], ["Texturing", "JUNIOR"]]],
// Soham Baviskar — Entry/Junior
["user-artist-023", [["Modeling", "JUNIOR"], ["UV Mapping", "JUNIOR"]]],
];
let userSkillCount = 0;
@ -400,14 +455,14 @@ async function main() {
// Map stages to appropriate artists by stage type
const stageToArtists: Record<string, string[]> = {
"model-prep": ["user-artist-001", "user-artist-002", "user-artist-010"],
"early-images": ["user-artist-003", "user-artist-004"],
"catalog-images": ["user-artist-003", "user-artist-004", "user-artist-006"],
"hero-images": ["user-artist-003", "user-artist-005", "user-artist-006"],
"packaging-images": ["user-artist-004", "user-artist-006"],
"photocomps": ["user-artist-005", "user-artist-009"],
"360-spin-animations": ["user-artist-007", "user-artist-008"],
"dynamic-spin": ["user-artist-007", "user-artist-008"],
"model-prep": ["user-artist-005", "user-artist-008", "user-artist-017", "user-artist-023"],
"early-images": ["user-artist-002", "user-artist-006", "user-artist-010"],
"catalog-images": ["user-artist-011", "user-artist-021", "user-artist-014", "user-artist-015", "user-artist-002"],
"hero-images": ["user-artist-011", "user-artist-021", "user-artist-001", "user-artist-002"],
"packaging-images": ["user-artist-006", "user-artist-010", "user-artist-013", "user-artist-019"],
"photocomps": ["user-artist-001", "user-artist-015", "user-artist-014"],
"360-spin-animations": ["user-artist-022", "user-artist-003", "user-artist-009", "user-artist-024"],
"dynamic-spin": ["user-artist-022", "user-artist-020", "user-artist-018", "user-artist-007"],
};
// Clear existing assignments (to allow re-runs)

View file

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthSession, serverError } from "@/lib/api-utils";
import { listExecutions } from "@/lib/services/automation-service";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ ruleId: string }> }
) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { ruleId } = await params;
const limit = Number(req.nextUrl.searchParams.get("limit")) || 50;
const executions = await listExecutions(ruleId, { limit });
return NextResponse.json(executions);
} catch (error) {
return serverError(error);
}
}

View file

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthSession, notFound, serverError } from "@/lib/api-utils";
import {
getAutomationRule,
updateAutomationRule,
deleteAutomationRule,
} from "@/lib/services/automation-service";
import { validateTrigger } from "@/lib/automation/rule-engine";
import { validateActions } from "@/lib/automation/action-executor";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ ruleId: string }> }
) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { ruleId } = await params;
const rule = await getAutomationRule(ruleId);
if (!rule) return notFound("Rule not found");
return NextResponse.json(rule);
} catch (error) {
return serverError(error);
}
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ ruleId: string }> }
) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
if (session!.user.role === "ARTIST") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { ruleId } = await params;
const body = await req.json();
if (body.trigger) {
const tv = validateTrigger(body.trigger);
if (!tv.valid) {
return NextResponse.json(
{ error: "Invalid trigger", details: tv.errors },
{ status: 400 }
);
}
}
if (body.actions) {
const av = validateActions(body.actions);
if (!av.valid) {
return NextResponse.json(
{ error: "Invalid actions", details: av.errors },
{ status: 400 }
);
}
}
const rule = await updateAutomationRule(ruleId, body);
return NextResponse.json(rule);
} catch (error) {
return serverError(error);
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ ruleId: string }> }
) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
if (session!.user.role === "ARTIST") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { ruleId } = await params;
await deleteAutomationRule(ruleId);
return NextResponse.json({ success: true });
} catch (error) {
return serverError(error);
}
}

View file

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
import {
listAutomationRules,
createAutomationRule,
} from "@/lib/services/automation-service";
import { validateTrigger } from "@/lib/automation/rule-engine";
import { validateActions } from "@/lib/automation/action-executor";
export async function GET() {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const rules = await listAutomationRules(session!.user.organizationId!);
return NextResponse.json(rules);
} catch (error) {
return serverError(error);
}
}
export async function POST(req: NextRequest) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
// Only producers and admins can create rules
if (session!.user.role === "ARTIST") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { name, description, trigger, actions, isEnabled } = body;
if (!name || !trigger || !actions) {
return badRequest("name, trigger, and actions are required");
}
// Validate trigger
const triggerValidation = validateTrigger(trigger);
if (!triggerValidation.valid) {
return NextResponse.json(
{ error: "Invalid trigger", details: triggerValidation.errors },
{ status: 400 }
);
}
// Validate actions
const actionsValidation = validateActions(actions);
if (!actionsValidation.valid) {
return NextResponse.json(
{ error: "Invalid actions", details: actionsValidation.errors },
{ status: 400 }
);
}
const rule = await createAutomationRule({
name,
description,
organizationId: session!.user.organizationId!,
createdById: session!.user.id,
trigger,
actions,
isEnabled,
});
return NextResponse.json(rule, { status: 201 });
} catch (error) {
return serverError(error);
}
}

96
src/app/api/chat/route.ts Normal file
View file

@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthSession, serverError } from "@/lib/api-utils";
import { chat, getProviderStatus } from "@/lib/chat/provider";
import { executeTool } from "@/lib/chat/tool-executor";
export async function POST(req: NextRequest) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { messages, context } = await req.json();
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return NextResponse.json(
{ error: "messages array is required" },
{ status: 400 }
);
}
const userId = session!.user.id;
const organizationId = session!.user.organizationId!;
// Send to AI provider (Claude → Ollama fallback)
let response = await chat(messages);
// Collect all cache keys to invalidate
const allInvalidateKeys: string[] = [];
// If the AI wants to call tools, execute them and continue the conversation
let iterations = 0;
const MAX_ITERATIONS = 5; // prevent runaway tool loops
while (response.toolCalls.length > 0 && iterations < MAX_ITERATIONS) {
iterations++;
const toolResults: { tool_use_id: string; content: string }[] = [];
for (const toolCall of response.toolCalls) {
const result = await executeTool(toolCall.name, toolCall.input, {
organizationId,
userId,
});
// Track cache keys for the frontend
if (result.invalidateKeys.length > 0) {
allInvalidateKeys.push(...result.invalidateKeys);
}
toolResults.push({
tool_use_id: toolCall.id,
content: JSON.stringify(
result.success
? result.data
: { error: result.error }
),
});
}
// Build the full conversation including the assistant's tool calls and the results
const updatedMessages = [
...messages,
{
role: "assistant" as const,
content: response.content || "(tool use)",
},
];
// Continue conversation with tool results
response = await chat(updatedMessages, toolResults);
}
return NextResponse.json({
content: response.content,
provider: response.provider,
invalidateKeys: [...new Set(allInvalidateKeys)],
});
} catch (error) {
console.error("[Chat API]", error);
return serverError(error);
}
}
/**
* GET /api/chat returns provider health status.
*/
export async function GET() {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const status = await getProviderStatus();
return NextResponse.json(status);
} catch (error) {
return serverError(error);
}
}

View file

@ -0,0 +1,247 @@
"use client";
import { useState, useRef, useEffect } from "react";
import {
MessageSquare,
X,
Send,
Trash2,
Loader2,
Bot,
User,
Zap,
Server,
StopCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { useChat, type ChatMessage } from "@/hooks/use-chat";
import { cn } from "@/lib/utils";
function ProviderBadge({ provider }: { provider: "claude" | "ollama" | "none" }) {
if (provider === "none") return null;
return (
<div
className={cn(
"flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium",
provider === "claude"
? "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
)}
>
{provider === "claude" ? (
<Zap className="h-2.5 w-2.5" />
) : (
<Server className="h-2.5 w-2.5" />
)}
{provider === "claude" ? "Claude" : "Ollama (local)"}
</div>
);
}
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === "user";
return (
<div className={cn("flex gap-2", isUser ? "flex-row-reverse" : "flex-row")}>
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
isUser
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
)}
>
{isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
</div>
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
isUser
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "bg-[var(--muted)]"
)}
>
{message.isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="text-xs text-[var(--muted-foreground)]">Thinking...</span>
</div>
) : (
<div className="whitespace-pre-wrap break-words">
{message.content}
</div>
)}
{!isUser && message.provider && !message.isLoading && (
<div className="mt-1">
<ProviderBadge provider={message.provider} />
</div>
)}
</div>
</div>
);
}
export function ChatPanel() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { messages, isLoading, provider, sendMessage, clearMessages, cancelRequest } =
useChat();
// Listen for custom event from command palette
useEffect(() => {
const handleOpen = () => setOpen(true);
window.addEventListener("open-chat", handleOpen);
return () => window.removeEventListener("open-chat", handleOpen);
}, []);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
// Focus input when panel opens
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 200);
}
}, [open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage(input);
setInput("");
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9"
title="AI Assistant"
>
<MessageSquare className="h-5 w-5" />
{provider !== "none" && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-[var(--background)]",
provider === "claude" ? "bg-purple-500" : "bg-amber-500"
)}
/>
)}
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="flex w-full flex-col p-0 sm:max-w-[420px]"
>
{/* Header */}
<SheetHeader className="border-b px-4 py-3">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2 text-sm font-semibold">
<Bot className="h-4 w-4" />
AI Assistant
</SheetTitle>
<div className="flex items-center gap-1.5">
<ProviderBadge provider={provider} />
{messages.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={clearMessages}
title="Clear chat"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</SheetHeader>
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<Bot className="h-10 w-10 text-[var(--muted-foreground)] mb-3" />
<p className="text-sm font-medium">How can I help?</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1 max-w-[260px]">
Ask about project status, assign artists, create deliverables, or check what&apos;s overdue.
</p>
<div className="mt-4 space-y-1.5 w-full max-w-[280px]">
{[
"What's overdue this week?",
"Who has capacity right now?",
"Show me all blocked stages",
].map((suggestion) => (
<button
key={suggestion}
className="w-full rounded-lg border px-3 py-2 text-left text-xs hover:bg-[var(--muted)] transition-colors"
onClick={() => {
setInput(suggestion);
sendMessage(suggestion);
}}
>
{suggestion}
</button>
))}
</div>
</div>
) : (
messages.map((msg) => <MessageBubble key={msg.id} message={msg} />)
)}
</div>
{/* Input */}
<div className="border-t px-4 py-3">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 rounded-lg border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--ring)] disabled:opacity-50"
/>
{isLoading ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0"
onClick={cancelRequest}
title="Stop"
>
<StopCircle className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
size="icon"
className="h-9 w-9 shrink-0"
disabled={!input.trim()}
>
<Send className="h-4 w-4" />
</Button>
)}
</form>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -11,9 +11,7 @@ import {
Moon,
Sun,
Monitor,
FileSpreadsheet,
Search,
Sparkles,
Bot,
} from "lucide-react";
import { useTheme } from "next-themes";
import {
@ -66,14 +64,14 @@ export function CommandPalette() {
<CommandItem
onSelect={() =>
runCommand(() =>
window.dispatchEvent(new CustomEvent("open-smart-search"))
window.dispatchEvent(new CustomEvent("open-chat"))
)
}
>
<Sparkles className="mr-2 h-4 w-4" />
Smart Search
<Bot className="mr-2 h-4 w-4" />
AI Assistant
<span className="ml-auto text-[10px] text-[var(--muted-foreground)]">
AI-powered
Ask anything
</span>
</CommandItem>
</CommandGroup>

View file

@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import {
@ -13,7 +12,6 @@ import {
AlertTriangle,
Unlock,
Clock,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/layout/theme-toggle";
@ -25,14 +23,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
useUnreadCount,
useNotifications,
useMarkAsRead,
useMarkAllAsRead,
} from "@/hooks/use-notifications";
import { SmartSearchPanel } from "@/components/search/smart-search-panel";
import { ChatPanel } from "@/components/chat/chat-panel";
import { cn } from "@/lib/utils";
const NOTIF_ICONS: Record<string, typeof Bell> = {
@ -47,7 +44,6 @@ const NOTIF_ICONS: Record<string, typeof Bell> = {
};
export function Topbar() {
const [searchOpen, setSearchOpen] = useState(false);
const { data: countData } = useUnreadCount();
const { data: notifications } = useNotifications(10);
const markRead = useMarkAsRead();
@ -56,13 +52,6 @@ export function Topbar() {
const unreadCount = countData?.count ?? 0;
const items = (notifications as any[]) ?? [];
// Listen for custom event from command palette
useEffect(() => {
const handleOpenSearch = () => setSearchOpen(true);
window.addEventListener("open-smart-search", handleOpenSearch);
return () => window.removeEventListener("open-smart-search", handleOpenSearch);
}, []);
return (
<>
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)]/80 backdrop-blur-sm px-4 md:px-5 sticky top-0 z-10" role="banner">
@ -72,21 +61,8 @@ export function Topbar() {
</div>
<div className="flex items-center gap-1.5">
{/* Smart Search button */}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSearchOpen(true)}
aria-label="Smart Search"
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Smart Search</TooltipContent>
</Tooltip>
{/* AI Assistant */}
<ChatPanel />
<ThemeToggle />
@ -202,11 +178,6 @@ export function Topbar() {
</div>
</header>
{/* Smart Search Panel — rendered outside header to avoid backdrop-filter containing block */}
<SmartSearchPanel
open={searchOpen}
onClose={() => setSearchOpen(false)}
/>
</>
);
}

138
src/hooks/use-chat.ts Normal file
View file

@ -0,0 +1,138 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
provider?: "claude" | "ollama";
isLoading?: boolean;
}
export function useChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [provider, setProvider] = useState<"claude" | "ollama" | "none">("none");
const queryClient = useQueryClient();
const abortRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isLoading) return;
// Add user message
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: content.trim(),
timestamp: new Date(),
};
// Add placeholder for assistant response
const loadingMsg: ChatMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: "",
timestamp: new Date(),
isLoading: true,
};
setMessages((prev) => [...prev, userMsg, loadingMsg]);
setIsLoading(true);
try {
// Build message history for the API
const apiMessages = [...messages, userMsg].map((m) => ({
role: m.role,
content: m.content,
}));
abortRef.current = new AbortController();
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: apiMessages }),
signal: abortRef.current.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.error || `Chat request failed: ${response.status}`
);
}
const data = await response.json();
// Update the loading message with the actual response
setMessages((prev) =>
prev.map((m) =>
m.id === loadingMsg.id
? {
...m,
content: data.content,
provider: data.provider,
isLoading: false,
}
: m
)
);
// Update provider indicator
if (data.provider) {
setProvider(data.provider);
}
// Invalidate TanStack Query caches for any mutated data
if (data.invalidateKeys && data.invalidateKeys.length > 0) {
for (const key of data.invalidateKeys) {
queryClient.invalidateQueries({ queryKey: [key] });
}
// Also invalidate broader dashboard/timeline queries
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
}
} catch (error) {
if ((error as Error).name === "AbortError") return;
setMessages((prev) =>
prev.map((m) =>
m.id === loadingMsg.id
? {
...m,
content: `Sorry, I encountered an error: ${(error as Error).message}`,
isLoading: false,
}
: m
)
);
} finally {
setIsLoading(false);
abortRef.current = null;
}
},
[messages, isLoading, queryClient]
);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
const cancelRequest = useCallback(() => {
abortRef.current?.abort();
setIsLoading(false);
setMessages((prev) => prev.filter((m) => !m.isLoading));
}, []);
return {
messages,
isLoading,
provider,
sendMessage,
clearMessages,
cancelRequest,
};
}

View file

@ -0,0 +1,338 @@
/**
* Action Executor runs the actions defined in an automation rule.
*
* Action format (JSON stored in AutomationRule.actions):
* [
* { type: "update_stage_status", params: { status: "NOT_STARTED" } },
* { type: "send_notification", params: { title: "...", message: "...", roles: ["PRODUCER"] } },
* { type: "create_assignment", params: { userId: "auto" } }
* ]
*
* Each action returns a result object describing what happened.
*/
import { prisma } from "@/lib/prisma";
import type { AutomationEvent } from "./event-bus";
import { createNotifications } from "@/lib/services/notification-service";
export interface ActionDefinition {
type: "update_stage_status" | "send_notification" | "create_assignment" | "send_webhook";
params: Record<string, any>;
}
export interface ActionResult {
actionType: string;
success: boolean;
detail: string;
error?: string;
}
/**
* Execute a single action in the context of a triggered event.
*/
async function executeAction(
action: ActionDefinition,
event: AutomationEvent
): Promise<ActionResult> {
try {
switch (action.type) {
case "update_stage_status":
return await executeUpdateStageStatus(action, event);
case "send_notification":
return await executeSendNotification(action, event);
case "create_assignment":
return await executeCreateAssignment(action, event);
case "send_webhook":
return await executeSendWebhook(action, event);
default:
return {
actionType: action.type,
success: false,
detail: `Unknown action type: ${action.type}`,
};
}
} catch (error) {
return {
actionType: action.type,
success: false,
detail: "Action threw an error",
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Action: Update a stage's status.
* Uses stageId from the event payload or params.targetStageId.
*/
async function executeUpdateStageStatus(
action: ActionDefinition,
event: AutomationEvent
): Promise<ActionResult> {
const stageId = action.params.stageId || event.payload.stageId;
const newStatus = action.params.status;
if (!stageId || !newStatus) {
return {
actionType: action.type,
success: false,
detail: "Missing stageId or status in params",
};
}
await prisma.deliverableStage.update({
where: { id: stageId },
data: { status: newStatus },
});
return {
actionType: action.type,
success: true,
detail: `Stage ${stageId} updated to ${newStatus}`,
};
}
/**
* Action: Send a notification to specified users or roles.
*/
async function executeSendNotification(
action: ActionDefinition,
event: AutomationEvent
): Promise<ActionResult> {
const { title, message, roles, userIds } = action.params;
if (!title || !message) {
return {
actionType: action.type,
success: false,
detail: "Missing title or message",
};
}
// Determine target users
let targetUserIds: string[] = userIds || [];
if (roles && Array.isArray(roles) && roles.length > 0) {
const roleUsers = await prisma.user.findMany({
where: {
organizationId: event.organizationId,
role: { in: roles },
},
select: { id: true },
});
targetUserIds = [
...targetUserIds,
...roleUsers.map((u) => u.id),
];
}
// Deduplicate
targetUserIds = [...new Set(targetUserIds)];
if (targetUserIds.length === 0) {
return {
actionType: action.type,
success: true,
detail: "No target users found — notification skipped",
};
}
// Interpolate event data into title/message
const interpolated = (str: string) =>
str.replace(/\{(\w+)\}/g, (_, key) => event.payload[key] ?? `{${key}}`);
await createNotifications(
targetUserIds.map((userId) => ({
userId,
type: "STATUS_CHANGE" as const,
title: interpolated(title),
message: interpolated(message),
link: action.params.link
? interpolated(action.params.link)
: undefined,
}))
);
return {
actionType: action.type,
success: true,
detail: `Notification sent to ${targetUserIds.length} user(s)`,
};
}
/**
* Action: Auto-assign an artist to a stage.
* If userId is "auto", uses the skill-based suggestion system.
*/
async function executeCreateAssignment(
action: ActionDefinition,
event: AutomationEvent
): Promise<ActionResult> {
const stageId = action.params.stageId || event.payload.stageId;
let userId = action.params.userId;
if (!stageId) {
return {
actionType: action.type,
success: false,
detail: "Missing stageId",
};
}
// "auto" mode: pick the best available artist using skill matching
if (userId === "auto") {
const stage = await prisma.deliverableStage.findUnique({
where: { id: stageId },
select: { templateId: true },
});
if (!stage) {
return {
actionType: action.type,
success: false,
detail: "Stage not found for auto-assignment",
};
}
// Import dynamically to avoid circular deps
const { getSuggestedArtists } = await import(
"@/lib/services/skill-service"
);
const suggestions = await getSuggestedArtists(
stage.templateId,
event.organizationId
);
if (suggestions.length === 0) {
return {
actionType: action.type,
success: false,
detail: "No available artists for auto-assignment",
};
}
userId = suggestions[0].userId;
}
if (!userId) {
return {
actionType: action.type,
success: false,
detail: "Missing userId",
};
}
await prisma.stageAssignment.upsert({
where: {
deliverableStageId_userId: {
deliverableStageId: stageId,
userId,
},
},
update: {},
create: {
deliverableStageId: stageId,
userId,
role: action.params.role || "LEAD",
},
});
return {
actionType: action.type,
success: true,
detail: `Assigned user ${userId} to stage ${stageId}`,
};
}
/**
* Action: POST to an external webhook URL.
*/
async function executeSendWebhook(
action: ActionDefinition,
event: AutomationEvent
): Promise<ActionResult> {
const { url } = action.params;
if (!url) {
return {
actionType: action.type,
success: false,
detail: "Missing webhook URL",
};
}
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: event.type,
payload: event.payload,
timestamp: event.timestamp.toISOString(),
}),
signal: AbortSignal.timeout(10000), // 10s timeout
});
return {
actionType: action.type,
success: response.ok,
detail: `Webhook ${response.ok ? "succeeded" : "failed"}: ${response.status}`,
};
} catch (error) {
return {
actionType: action.type,
success: false,
detail: "Webhook request failed",
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Execute all actions for a rule and return collected results.
*/
export async function executeActions(
actions: ActionDefinition[],
event: AutomationEvent
): Promise<ActionResult[]> {
const results: ActionResult[] = [];
for (const action of actions) {
const result = await executeAction(action, event);
results.push(result);
}
return results;
}
/**
* Validate an action definition (for rule creation UI).
*/
export function validateActions(
actions: any
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!Array.isArray(actions)) {
return { valid: false, errors: ["Actions must be an array"] };
}
const validTypes = [
"update_stage_status",
"send_notification",
"create_assignment",
"send_webhook",
];
for (let i = 0; i < actions.length; i++) {
const a = actions[i];
if (!validTypes.includes(a.type)) {
errors.push(`Action ${i}: invalid type "${a.type}"`);
}
if (!a.params || typeof a.params !== "object") {
errors.push(`Action ${i}: missing params`);
}
}
return { valid: errors.length === 0, errors };
}

View file

@ -0,0 +1,130 @@
/**
* Event Bus dispatches events to the automation rule engine.
*
* Supported events:
* stage.status_changed stage status was updated
* revision.submitted new revision uploaded
* assignment.created artist assigned to stage
* deadline.approaching stage/deliverable due soon
* deadline.passed stage/deliverable overdue
*/
export type AutomationEventType =
| "stage.status_changed"
| "revision.submitted"
| "assignment.created"
| "deadline.approaching"
| "deadline.passed";
export interface AutomationEvent {
type: AutomationEventType;
organizationId: string;
timestamp: Date;
payload: Record<string, any>;
}
export interface StageStatusChangedPayload {
stageId: string;
stageName: string;
stageSlug: string;
deliverableId: string;
deliverableName: string;
projectId: string;
projectName: string;
oldStatus: string;
newStatus: string;
isCriticalGate: boolean;
}
export interface RevisionSubmittedPayload {
revisionId: string;
stageId: string;
stageName: string;
deliverableId: string;
deliverableName: string;
projectId: string;
roundNumber: number;
}
export interface AssignmentCreatedPayload {
assignmentId: string;
stageId: string;
stageName: string;
userId: string;
userName: string;
deliverableId: string;
projectId: string;
}
type EventHandler = (event: AutomationEvent) => Promise<void>;
const handlers: EventHandler[] = [];
/**
* Register a handler to be called when any automation event fires.
*/
export function onAutomationEvent(handler: EventHandler) {
handlers.push(handler);
}
/**
* Dispatch an event to all registered handlers.
* Errors in handlers are caught and logged they don't block the caller.
*/
export async function dispatchEvent(event: AutomationEvent): Promise<void> {
for (const handler of handlers) {
try {
await handler(event);
} catch (error) {
console.error(
`[EventBus] Handler error for ${event.type}:`,
error instanceof Error ? error.message : error
);
}
}
}
/**
* Helper: create and dispatch a stage.status_changed event.
*/
export function emitStageStatusChanged(
organizationId: string,
payload: StageStatusChangedPayload
) {
return dispatchEvent({
type: "stage.status_changed",
organizationId,
timestamp: new Date(),
payload,
});
}
/**
* Helper: create and dispatch a revision.submitted event.
*/
export function emitRevisionSubmitted(
organizationId: string,
payload: RevisionSubmittedPayload
) {
return dispatchEvent({
type: "revision.submitted",
organizationId,
timestamp: new Date(),
payload,
});
}
/**
* Helper: create and dispatch an assignment.created event.
*/
export function emitAssignmentCreated(
organizationId: string,
payload: AssignmentCreatedPayload
) {
return dispatchEvent({
type: "assignment.created",
organizationId,
timestamp: new Date(),
payload,
});
}

View file

@ -0,0 +1,139 @@
/**
* Rule Evaluation Engine matches automation events against stored rules.
*
* Trigger format (JSON stored in AutomationRule.trigger):
* {
* event: "stage.status_changed",
* conditions: [
* { field: "newStatus", operator: "equals", value: "APPROVED" },
* { field: "stageSlug", operator: "in", value: ["catalog-images", "model-prep"] },
* { field: "isCriticalGate", operator: "equals", value: true }
* ]
* }
*
* Supported operators: equals, not_equals, in, not_in, contains, gt, lt, gte, lte
*/
import type { AutomationEvent } from "./event-bus";
export interface TriggerCondition {
field: string;
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "gt" | "lt" | "gte" | "lte";
value: any;
}
export interface TriggerDefinition {
event: string;
conditions: TriggerCondition[];
}
/**
* Check if a single condition matches against the event payload.
*/
function evaluateCondition(
condition: TriggerCondition,
payload: Record<string, any>
): boolean {
const fieldValue = payload[condition.field];
switch (condition.operator) {
case "equals":
return fieldValue === condition.value;
case "not_equals":
return fieldValue !== condition.value;
case "in":
return Array.isArray(condition.value) && condition.value.includes(fieldValue);
case "not_in":
return Array.isArray(condition.value) && !condition.value.includes(fieldValue);
case "contains":
return typeof fieldValue === "string" && fieldValue.includes(condition.value);
case "gt":
return typeof fieldValue === "number" && fieldValue > condition.value;
case "lt":
return typeof fieldValue === "number" && fieldValue < condition.value;
case "gte":
return typeof fieldValue === "number" && fieldValue >= condition.value;
case "lte":
return typeof fieldValue === "number" && fieldValue <= condition.value;
default:
return false;
}
}
/**
* Check if an event matches a rule's trigger definition.
* All conditions must match (AND logic).
*/
export function matchesRule(
event: AutomationEvent,
trigger: TriggerDefinition
): boolean {
// Event type must match
if (event.type !== trigger.event) {
return false;
}
// All conditions must pass
for (const condition of trigger.conditions) {
if (!evaluateCondition(condition, event.payload)) {
return false;
}
}
return true;
}
/**
* Validate a trigger definition (for rule creation UI).
*/
export function validateTrigger(
trigger: any
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!trigger || typeof trigger !== "object") {
return { valid: false, errors: ["Trigger must be an object"] };
}
const validEvents = [
"stage.status_changed",
"revision.submitted",
"assignment.created",
"deadline.approaching",
"deadline.passed",
];
if (!validEvents.includes(trigger.event)) {
errors.push(
`Invalid event type "${trigger.event}". Must be one of: ${validEvents.join(", ")}`
);
}
if (!Array.isArray(trigger.conditions)) {
errors.push("conditions must be an array");
} else {
const validOperators = [
"equals",
"not_equals",
"in",
"not_in",
"contains",
"gt",
"lt",
"gte",
"lte",
];
for (let i = 0; i < trigger.conditions.length; i++) {
const c = trigger.conditions[i];
if (!c.field) errors.push(`Condition ${i}: missing field`);
if (!validOperators.includes(c.operator)) {
errors.push(
`Condition ${i}: invalid operator "${c.operator}"`
);
}
if (c.value === undefined) errors.push(`Condition ${i}: missing value`);
}
}
return { valid: errors.length === 0, errors };
}

253
src/lib/chat/provider.ts Normal file
View file

@ -0,0 +1,253 @@
/**
* Chat Provider abstracts Claude API vs Ollama fallback.
* Default: Claude API. Falls back to Ollama if Claude is unreachable.
*/
import { TOOL_DEFINITIONS } from "./tool-definitions";
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ToolCall {
id: string;
name: string;
input: Record<string, any>;
}
export interface ChatResponse {
content: string;
toolCalls: ToolCall[];
provider: "claude" | "ollama";
stopReason: string;
}
const SYSTEM_PROMPT = `You are an AI assistant for the HP CG Production Tracker — a tool used by producers to manage CG rendering projects for HP products.
You help producers by:
- Answering questions about project status, deadlines, and workload
- Creating and updating projects, deliverables, and pipeline stages
- Assigning artists based on skills and availability
- Flagging overdue items and bottlenecks
Important rules:
1. For any MUTATION (create, update, assign, delete), ALWAYS call the tool with dryRun=true first to show the user what will happen. Then ask for confirmation before executing with dryRun=false.
2. Be concise and specific producers are busy.
3. When showing data, format it in a scannable way (bullet points, tables).
4. If you don't have enough information, ask for clarification.
5. Reference projects by name or code, not internal IDs, when talking to the user.`;
/**
* Check if Claude API is reachable.
*/
export async function checkClaudeHealth(): Promise<boolean> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return false;
try {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
}),
signal: AbortSignal.timeout(5000),
});
return response.ok || response.status === 400; // 400 means API is reachable but request was bad
} catch {
return false;
}
}
/**
* Check if Ollama is available with a chat-capable model.
*/
export async function checkOllamaHealth(): Promise<boolean> {
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
try {
const response = await fetch(`${host}/api/tags`, {
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}
/**
* Send a chat request to Claude API.
*/
async function chatWithClaude(
messages: ChatMessage[],
toolResults?: { tool_use_id: string; content: string }[]
): Promise<ChatResponse> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not configured");
// Build Claude-format messages
const claudeMessages: any[] = [];
for (const msg of messages) {
if (msg.role === "system") continue; // system goes in system param
claudeMessages.push({ role: msg.role, content: msg.content });
}
// Add tool results if provided
if (toolResults && toolResults.length > 0) {
claudeMessages.push({
role: "user",
content: toolResults.map((tr) => ({
type: "tool_result",
tool_use_id: tr.tool_use_id,
content: tr.content,
})),
});
}
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: claudeMessages,
tools: TOOL_DEFINITIONS,
}),
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Claude API error ${response.status}: ${errorText}`);
}
const result = await response.json();
// Extract text content and tool calls
let content = "";
const toolCalls: ToolCall[] = [];
for (const block of result.content) {
if (block.type === "text") {
content += block.text;
} else if (block.type === "tool_use") {
toolCalls.push({
id: block.id,
name: block.name,
input: block.input,
});
}
}
return {
content,
toolCalls,
provider: "claude",
stopReason: result.stop_reason,
};
}
/**
* Send a chat request to local Ollama (fallback).
* Note: Ollama's tool calling is less reliable than Claude's.
*/
async function chatWithOllama(
messages: ChatMessage[]
): Promise<ChatResponse> {
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
const model = process.env.OLLAMA_LLM_MODEL || "qwen3:1.7b";
// For Ollama, include the system prompt and tool descriptions in the prompt
const ollamaMessages = [
{ role: "system", content: SYSTEM_PROMPT },
...messages.map((m) => ({ role: m.role, content: m.content })),
];
const response = await fetch(`${host}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: false,
options: { temperature: 0.3 },
}),
signal: AbortSignal.timeout(60000),
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const result = await response.json();
return {
content: result.message?.content || "I'm having trouble processing that request.",
toolCalls: [], // Ollama fallback doesn't use structured tool calling
provider: "ollama",
stopReason: "end_turn",
};
}
/**
* Send a chat request tries Claude first, falls back to Ollama.
*/
export async function chat(
messages: ChatMessage[],
toolResults?: { tool_use_id: string; content: string }[]
): Promise<ChatResponse> {
// Try Claude first
const hasClaudeKey = !!process.env.ANTHROPIC_API_KEY;
if (hasClaudeKey) {
try {
return await chatWithClaude(messages, toolResults);
} catch (error) {
console.warn(
"[ChatProvider] Claude failed, falling back to Ollama:",
error instanceof Error ? error.message : error
);
}
}
// Fallback to Ollama
try {
return await chatWithOllama(messages);
} catch (error) {
throw new Error(
`Both Claude and Ollama are unavailable. Claude: ${hasClaudeKey ? "key set but failed" : "no API key"}. Ollama: ${error instanceof Error ? error.message : "unreachable"}`
);
}
}
/**
* Get the current provider status for the UI indicator.
*/
export async function getProviderStatus(): Promise<{
primary: { name: "claude"; available: boolean };
fallback: { name: "ollama"; available: boolean };
activeProvider: "claude" | "ollama" | "none";
}> {
const [claudeOk, ollamaOk] = await Promise.all([
checkClaudeHealth(),
checkOllamaHealth(),
]);
return {
primary: { name: "claude", available: claudeOk },
fallback: { name: "ollama", available: ollamaOk },
activeProvider: claudeOk ? "claude" : ollamaOk ? "ollama" : "none",
};
}

View file

@ -0,0 +1,322 @@
/**
* Tool Definitions maps our service layer to Claude-compatible tool schemas.
* Each tool has a name, description, and input_schema (JSON Schema).
* These are passed to the Claude API's `tools` parameter.
*/
export interface ToolDefinition {
name: string;
description: string;
input_schema: {
type: "object";
properties: Record<string, any>;
required?: string[];
};
}
export const TOOL_DEFINITIONS: ToolDefinition[] = [
// ─── Read Operations ────────────────────────────────
{
name: "list_projects",
description:
"List all projects in the organization. Returns project names, codes, statuses, priorities, and deliverable counts.",
input_schema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"],
description: "Filter by project status (optional)",
},
},
},
},
{
name: "get_project",
description:
"Get detailed information about a specific project including all its deliverables and pipeline stages.",
input_schema: {
type: "object",
properties: {
projectId: { type: "string", description: "The project ID" },
},
required: ["projectId"],
},
},
{
name: "list_deliverables",
description:
"List all deliverables for a specific project with their stage statuses and artist assignments.",
input_schema: {
type: "object",
properties: {
projectId: { type: "string", description: "The project ID" },
},
required: ["projectId"],
},
},
{
name: "get_blocked_stages",
description:
"Get all pipeline stages that are currently blocked, showing what prerequisites they're waiting on. Optionally filter by project.",
input_schema: {
type: "object",
properties: {
projectId: {
type: "string",
description: "Filter to a specific project (optional)",
},
},
},
},
{
name: "list_overdue",
description:
"Get all overdue deliverables and stages sorted by most overdue first, including assigned artist info.",
input_schema: {
type: "object",
properties: {
projectId: {
type: "string",
description: "Filter to a specific project (optional)",
},
limit: {
type: "number",
description: "Max items to return (optional, default all)",
},
},
},
},
{
name: "get_available_artists",
description:
"Get artists who have capacity (below their max concurrent assignments). Optionally filter by department or skills.",
input_schema: {
type: "object",
properties: {
departmentFilter: {
type: "string",
description: "Filter by department name (optional)",
},
},
},
},
{
name: "get_workload",
description:
"Get workload data for all team members showing assignments per week, utilization, and capacity.",
input_schema: {
type: "object",
properties: {
numWeeks: {
type: "number",
description: "Number of weeks to show (default 8)",
},
projectId: {
type: "string",
description: "Filter to a specific project (optional)",
},
},
},
},
{
name: "get_suggested_artists",
description:
"Get artist suggestions for a specific stage based on skill match and current workload.",
input_schema: {
type: "object",
properties: {
stageTemplateId: {
type: "string",
description: "The pipeline stage template ID",
},
},
required: ["stageTemplateId"],
},
},
// ─── Mutation Operations (require confirmation) ─────
{
name: "create_project",
description:
"Create a new project. Use dryRun=true first to preview what will be created, then confirm with dryRun=false.",
input_schema: {
type: "object",
properties: {
projectCode: { type: "string", description: "Unique project code (e.g., HP-2026-Q3-001)" },
name: { type: "string", description: "Project name" },
status: { type: "string", enum: ["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"], description: "Project status" },
priority: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "URGENT"], description: "Priority level" },
description: { type: "string", description: "Project description (optional)" },
businessUnit: { type: "string", description: "Business unit (optional)" },
quarter: { type: "string", description: "Fiscal quarter e.g. FY26Q3 (optional)" },
dueDate: { type: "string", description: "Due date ISO string (optional)" },
dryRun: { type: "boolean", description: "If true, preview only — don't actually create" },
},
required: ["projectCode", "name", "status", "priority"],
},
},
{
name: "create_deliverable",
description:
"Create a new deliverable under a project. Auto-creates all 10 pipeline stages. Use dryRun=true to preview.",
input_schema: {
type: "object",
properties: {
projectId: { type: "string", description: "The project ID" },
name: { type: "string", description: "Deliverable name" },
priority: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "URGENT"] },
dueDate: { type: "string", description: "Due date ISO string (optional)" },
notes: { type: "string", description: "Notes (optional)" },
assetCount: { type: "number", description: "Number of assets (optional)" },
dryRun: { type: "boolean", description: "If true, preview only" },
},
required: ["projectId", "name", "priority"],
},
},
{
name: "bulk_create_deliverables",
description:
"Create multiple deliverables at once for a project. Use dryRun=true to preview.",
input_schema: {
type: "object",
properties: {
projectId: { type: "string", description: "The project ID" },
items: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
priority: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "URGENT"] },
dueDate: { type: "string" },
assetCount: { type: "number" },
},
required: ["name"],
},
description: "Array of deliverables to create",
},
dryRun: { type: "boolean", description: "If true, preview only" },
},
required: ["projectId", "items"],
},
},
{
name: "advance_stage",
description:
"Update a pipeline stage's status. Validates transitions and dependencies. Use dryRun=true to preview side effects (e.g., unblocking downstream stages).",
input_schema: {
type: "object",
properties: {
stageId: { type: "string", description: "The deliverable stage ID" },
status: {
type: "string",
enum: ["NOT_STARTED", "IN_PROGRESS", "IN_REVIEW", "CHANGES_REQUESTED", "APPROVED", "DELIVERED", "SKIPPED"],
description: "New status",
},
subStatus: { type: "string", description: "Optional sub-status (e.g., 'Clays posted', 'Lighting WIP')" },
dryRun: { type: "boolean", description: "If true, preview only" },
},
required: ["stageId", "status"],
},
},
{
name: "bulk_update_stages",
description:
"Update multiple stage statuses at once. Each is validated independently. Use dryRun=true to preview.",
input_schema: {
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
stageId: { type: "string" },
newStatus: { type: "string" },
subStatus: { type: "string" },
},
required: ["stageId", "newStatus"],
},
},
dryRun: { type: "boolean" },
},
required: ["items"],
},
},
{
name: "assign_artist",
description:
"Assign an artist to a pipeline stage. Use dryRun=true to preview.",
input_schema: {
type: "object",
properties: {
stageId: { type: "string", description: "The deliverable stage ID" },
userId: { type: "string", description: "The user ID to assign" },
role: { type: "string", enum: ["LEAD", "SUPPORT"], description: "Assignment role (default LEAD)" },
dryRun: { type: "boolean" },
},
required: ["stageId", "userId"],
},
},
{
name: "remove_assignment",
description: "Remove an artist's assignment from a stage.",
input_schema: {
type: "object",
properties: {
stageId: { type: "string", description: "The deliverable stage ID" },
userId: { type: "string", description: "The user ID to remove" },
},
required: ["stageId", "userId"],
},
},
{
name: "bulk_assign_artists",
description:
"Assign multiple artists to stages at once. Use dryRun=true to preview.",
input_schema: {
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
deliverableStageId: { type: "string" },
userId: { type: "string" },
role: { type: "string", enum: ["LEAD", "SUPPORT"] },
},
required: ["deliverableStageId", "userId"],
},
},
dryRun: { type: "boolean" },
},
required: ["items"],
},
},
{
name: "create_revision",
description: "Log a new revision round on a stage.",
input_schema: {
type: "object",
properties: {
stageId: { type: "string", description: "The deliverable stage ID" },
feedbackNotes: { type: "string", description: "Feedback notes (optional)" },
internalNotes: { type: "string", description: "Internal notes (optional)" },
},
required: ["stageId"],
},
},
{
name: "list_revisions",
description: "List all revision rounds for a specific stage.",
input_schema: {
type: "object",
properties: {
stageId: { type: "string", description: "The deliverable stage ID" },
},
required: ["stageId"],
},
},
];

View file

@ -0,0 +1,234 @@
/**
* Tool Executor maps tool names to service layer calls.
* Called by the chat API when Claude invokes a tool.
*/
import { listProjects, getProject, createProject } from "@/lib/services/project-service";
import {
listDeliverables,
createDeliverable,
bulkCreateDeliverables,
} from "@/lib/services/deliverable-service";
import {
updateStageStatus,
getBlockedStages,
bulkUpdateStageStatuses,
} from "@/lib/services/stage-service";
import {
assignUserToStage,
removeAssignment,
bulkAssignArtists,
} from "@/lib/services/assignment-service";
import { listOverdue } from "@/lib/services/deadline-service";
import { getAvailableArtists, getSuggestedArtists } from "@/lib/services/skill-service";
import { getWorkloadData } from "@/lib/services/workload-service";
import { createRevision, listRevisions } from "@/lib/services/revision-service";
/** Tools that mutate data — the chat system should flag these for confirmation */
export const MUTATION_TOOLS = new Set([
"create_project",
"create_deliverable",
"bulk_create_deliverables",
"advance_stage",
"bulk_update_stages",
"assign_artist",
"remove_assignment",
"bulk_assign_artists",
"create_revision",
]);
/** Cache keys that should be invalidated after a tool execution */
export const CACHE_INVALIDATION_MAP: Record<string, string[]> = {
create_project: ["projects"],
create_deliverable: ["projects", "deliverables"],
bulk_create_deliverables: ["projects", "deliverables"],
advance_stage: ["deliverables", "stages", "timeline"],
bulk_update_stages: ["deliverables", "stages", "timeline"],
assign_artist: ["assignments", "workload", "my-work"],
remove_assignment: ["assignments", "workload", "my-work"],
bulk_assign_artists: ["assignments", "workload", "my-work"],
create_revision: ["revisions", "stages"],
};
export interface ToolExecutionResult {
success: boolean;
data: any;
isMutation: boolean;
isDryRun: boolean;
invalidateKeys: string[];
error?: string;
}
/**
* Execute a tool by name with the given input.
*/
export async function executeTool(
toolName: string,
input: Record<string, any>,
context: { organizationId: string; userId: string }
): Promise<ToolExecutionResult> {
const isMutation = MUTATION_TOOLS.has(toolName);
const isDryRun = !!input.dryRun;
const invalidateKeys =
!isDryRun && isMutation ? CACHE_INVALIDATION_MAP[toolName] || [] : [];
try {
let data: any;
switch (toolName) {
// ── Read Operations ──
case "list_projects": {
const projects = await listProjects(context.organizationId);
// Filter by status if provided
data = input.status
? projects.filter((p: any) => p.status === input.status)
: projects;
break;
}
case "get_project": {
data = await getProject(input.projectId, context.organizationId);
if (!data) throw new Error("Project not found");
break;
}
case "list_deliverables": {
data = await listDeliverables(input.projectId);
break;
}
case "get_blocked_stages": {
data = await getBlockedStages(context.organizationId, {
projectId: input.projectId,
});
break;
}
case "list_overdue": {
data = await listOverdue(context.organizationId, {
projectId: input.projectId,
limit: input.limit,
});
break;
}
case "get_available_artists": {
data = await getAvailableArtists(context.organizationId, {
departmentFilter: input.departmentFilter,
});
break;
}
case "get_workload": {
data = await getWorkloadData(context.organizationId, {
numWeeks: input.numWeeks,
projectId: input.projectId,
});
break;
}
case "get_suggested_artists": {
data = await getSuggestedArtists(
input.stageTemplateId,
context.organizationId
);
break;
}
// ── Mutation Operations ──
case "create_project": {
const { dryRun, ...projectData } = input;
data = await createProject(
projectData as any,
context.organizationId,
{ dryRun }
);
break;
}
case "create_deliverable": {
const { projectId, dryRun, ...delivData } = input;
if (dryRun) {
data = await bulkCreateDeliverables(projectId, [delivData as any], { dryRun: true });
} else {
data = await createDeliverable(projectId, delivData as any);
}
break;
}
case "bulk_create_deliverables": {
const { projectId, items, dryRun } = input;
data = await bulkCreateDeliverables(projectId, items, { dryRun });
break;
}
case "advance_stage": {
const { stageId, status, subStatus, dryRun } = input;
data = await updateStageStatus(stageId, status, subStatus, { dryRun });
break;
}
case "bulk_update_stages": {
const { items, dryRun } = input;
data = await bulkUpdateStageStatuses(items, { dryRun });
break;
}
case "assign_artist": {
const { stageId, userId, role, dryRun } = input;
data = await assignUserToStage(stageId, userId, role || "LEAD", {
dryRun,
});
break;
}
case "remove_assignment": {
data = await removeAssignment(input.stageId, input.userId);
break;
}
case "bulk_assign_artists": {
const { items, dryRun } = input;
data = await bulkAssignArtists(items, { dryRun });
break;
}
case "create_revision": {
data = await createRevision(
input.stageId,
{
feedbackNotes: input.feedbackNotes,
internalNotes: input.internalNotes,
} as any
);
break;
}
case "list_revisions": {
data = await listRevisions(input.stageId);
break;
}
default:
return {
success: false,
data: null,
isMutation: false,
isDryRun: false,
invalidateKeys: [],
error: `Unknown tool: ${toolName}`,
};
}
return { success: true, data, isMutation, isDryRun, invalidateKeys };
} catch (error) {
return {
success: false,
data: null,
isMutation,
isDryRun,
invalidateKeys: [],
error: error instanceof Error ? error.message : String(error),
};
}
}

View file

@ -4,8 +4,39 @@ import type { AssignmentRole } from "@/generated/prisma/client";
export async function assignUserToStage(
deliverableStageId: string,
userId: string,
role: AssignmentRole = "LEAD"
role: AssignmentRole = "LEAD",
options: { dryRun?: boolean } = {}
) {
// Validate stage and user exist
const [stage, user] = await Promise.all([
prisma.deliverableStage.findUnique({
where: { id: deliverableStageId },
include: { template: true },
}),
prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, maxCapacity: true },
}),
]);
if (!stage) throw new Error("Stage not found");
if (!user) throw new Error("User not found");
// Check for existing assignment
const existing = await prisma.stageAssignment.findUnique({
where: { deliverableStageId_userId: { deliverableStageId, userId } },
});
if (options.dryRun) {
return {
dryRun: true,
action: existing ? "update_role" : "create_assignment",
stageName: stage.template.name,
userName: user.name || user.email,
role,
};
}
return prisma.stageAssignment.upsert({
where: {
deliverableStageId_userId: { deliverableStageId, userId },
@ -45,3 +76,119 @@ export async function getMyWork(userId: string) {
orderBy: { createdAt: "desc" },
});
}
// ─── Bulk Operations ────────────────────────────────────
export interface BulkAssignItem {
deliverableStageId: string;
userId: string;
role?: AssignmentRole;
}
export interface BulkAssignResult {
total: number;
succeeded: {
deliverableStageId: string;
stageName: string;
userId: string;
userName: string;
}[];
failed: {
deliverableStageId: string;
userId: string;
reason: string;
}[];
}
/**
* Assign multiple artists to stages in a single transaction.
* When dryRun is true, validates and returns preview without executing.
*/
export async function bulkAssignArtists(
items: BulkAssignItem[],
options: { dryRun?: boolean } = {}
): Promise<BulkAssignResult> {
if (items.length === 0) {
return { total: 0, succeeded: [], failed: [] };
}
// Fetch all referenced stages and users in bulk
const stageIds = [...new Set(items.map((i) => i.deliverableStageId))];
const userIds = [...new Set(items.map((i) => i.userId))];
const [stages, users] = await Promise.all([
prisma.deliverableStage.findMany({
where: { id: { in: stageIds } },
include: { template: true },
}),
prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
}),
]);
const stageMap = new Map(stages.map((s) => [s.id, s]));
const userMap = new Map(users.map((u) => [u.id, u]));
const succeeded: BulkAssignResult["succeeded"] = [];
const failed: BulkAssignResult["failed"] = [];
for (const item of items) {
const stage = stageMap.get(item.deliverableStageId);
if (!stage) {
failed.push({
deliverableStageId: item.deliverableStageId,
userId: item.userId,
reason: "Stage not found",
});
continue;
}
const user = userMap.get(item.userId);
if (!user) {
failed.push({
deliverableStageId: item.deliverableStageId,
userId: item.userId,
reason: "User not found",
});
continue;
}
succeeded.push({
deliverableStageId: item.deliverableStageId,
stageName: stage.template.name,
userId: item.userId,
userName: user.name || user.email,
});
}
if (options.dryRun) {
return { total: items.length, succeeded, failed };
}
// Execute all valid assignments in a transaction
if (succeeded.length > 0) {
await prisma.$transaction(async (tx) => {
for (const item of items) {
if (!stageMap.has(item.deliverableStageId) || !userMap.has(item.userId))
continue;
await tx.stageAssignment.upsert({
where: {
deliverableStageId_userId: {
deliverableStageId: item.deliverableStageId,
userId: item.userId,
},
},
update: { role: item.role || "LEAD" },
create: {
deliverableStageId: item.deliverableStageId,
userId: item.userId,
role: item.role || "LEAD",
},
});
}
});
}
return { total: items.length, succeeded, failed };
}

View file

@ -0,0 +1,181 @@
/**
* Automation Service CRUD for rules + wiring the event bus to rule evaluation + action execution.
*
* This is the main integration point that connects:
* event-bus.ts rule-engine.ts action-executor.ts
*
* On startup (import), it registers itself as an event bus handler.
*/
import { prisma } from "@/lib/prisma";
import {
onAutomationEvent,
type AutomationEvent,
} from "@/lib/automation/event-bus";
import { matchesRule, type TriggerDefinition } from "@/lib/automation/rule-engine";
import {
executeActions,
type ActionDefinition,
type ActionResult,
} from "@/lib/automation/action-executor";
// ─── Rule CRUD ──────────────────────────────────────────
export async function listAutomationRules(organizationId: string) {
return prisma.automationRule.findMany({
where: { organizationId },
include: {
createdBy: { select: { id: true, name: true, email: true } },
_count: { select: { executions: true } },
},
orderBy: { createdAt: "desc" },
});
}
export async function getAutomationRule(ruleId: string) {
return prisma.automationRule.findUnique({
where: { id: ruleId },
include: {
createdBy: { select: { id: true, name: true, email: true } },
executions: {
orderBy: { executedAt: "desc" },
take: 20,
},
},
});
}
export async function createAutomationRule(data: {
name: string;
description?: string;
organizationId: string;
createdById: string;
trigger: TriggerDefinition;
actions: ActionDefinition[];
isEnabled?: boolean;
}) {
return prisma.automationRule.create({
data: {
name: data.name,
description: data.description,
organizationId: data.organizationId,
createdById: data.createdById,
trigger: data.trigger as any,
actions: data.actions as any,
isEnabled: data.isEnabled ?? true,
},
});
}
export async function updateAutomationRule(
ruleId: string,
data: {
name?: string;
description?: string;
trigger?: TriggerDefinition;
actions?: ActionDefinition[];
isEnabled?: boolean;
}
) {
return prisma.automationRule.update({
where: { id: ruleId },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.description !== undefined
? { description: data.description }
: {}),
...(data.trigger !== undefined
? { trigger: data.trigger as any }
: {}),
...(data.actions !== undefined
? { actions: data.actions as any }
: {}),
...(data.isEnabled !== undefined ? { isEnabled: data.isEnabled } : {}),
},
});
}
export async function deleteAutomationRule(ruleId: string) {
return prisma.automationRule.delete({
where: { id: ruleId },
});
}
export async function listExecutions(
ruleId: string,
options: { limit?: number } = {}
) {
return prisma.automationExecution.findMany({
where: { ruleId },
orderBy: { executedAt: "desc" },
take: options.limit || 50,
});
}
// ─── Execution Logging ──────────────────────────────────
async function logExecution(
ruleId: string,
event: AutomationEvent,
results: ActionResult[]
) {
const allSucceeded = results.every((r) => r.success);
const anySucceeded = results.some((r) => r.success);
const status = allSucceeded
? "SUCCESS"
: anySucceeded
? "PARTIAL_FAILURE"
: "FAILURE";
const errors = results
.filter((r) => !r.success)
.map((r) => r.error || r.detail)
.join("; ");
await prisma.automationExecution.create({
data: {
ruleId,
triggeredBy: {
type: event.type,
payload: event.payload,
timestamp: event.timestamp.toISOString(),
},
result: results as any,
status,
error: errors || null,
},
});
}
// ─── Event Handler (registered on import) ───────────────
/**
* Core handler: when an event fires, find matching rules and execute them.
*/
async function handleAutomationEvent(event: AutomationEvent): Promise<void> {
// Fetch all enabled rules for this organization
const rules = await prisma.automationRule.findMany({
where: {
organizationId: event.organizationId,
isEnabled: true,
},
});
for (const rule of rules) {
const trigger = rule.trigger as unknown as TriggerDefinition;
const actions = rule.actions as unknown as ActionDefinition[];
// Check if this event matches the rule
if (!matchesRule(event, trigger)) continue;
// Execute all actions
const results = await executeActions(actions, event);
// Log the execution
await logExecution(rule.id, event, results);
}
}
// Register the handler with the event bus
onAutomationEvent(handleAutomationEvent);

View file

@ -142,6 +142,128 @@ export async function checkDeadlines(
return { approaching, overdue };
}
// ─── Flat Overdue List (for CLI / AI queries) ───────────
export interface OverdueItem {
id: string;
name: string;
type: "deliverable" | "stage";
dueDate: Date;
daysOverdue: number;
status: string;
projectId: string;
projectName: string;
projectCode: string;
assignedArtists: { id: string; name: string; email: string }[];
}
/**
* Get a flat, sorted list of all overdue items across the organization.
* Unlike checkDeadlines(), this returns assignee info and is optimized for
* AI agent consumption / direct display.
*/
export async function listOverdue(
organizationId: string,
options: { projectId?: string; limit?: number } = {}
): Promise<OverdueItem[]> {
const now = startOfDay(new Date());
const { projectId, limit } = options;
const projectFilter: any = { organizationId };
if (projectId) projectFilter.id = projectId;
// Overdue deliverables
const deliverables = await prisma.deliverable.findMany({
where: {
project: projectFilter,
dueDate: { lt: now },
status: { notIn: ["APPROVED", "ON_HOLD"] },
},
select: {
id: true,
name: true,
dueDate: true,
status: true,
project: { select: { id: true, name: true, projectCode: true } },
},
orderBy: { dueDate: "asc" },
});
// Overdue stages (with assignments)
const stages = await prisma.deliverableStage.findMany({
where: {
deliverable: { project: projectFilter },
dueDate: { lt: now },
status: { notIn: ["APPROVED", "DELIVERED", "SKIPPED"] },
},
select: {
id: true,
dueDate: true,
status: true,
template: { select: { name: true } },
assignments: {
select: { user: { select: { id: true, name: true, email: true } } },
},
deliverable: {
select: {
name: true,
project: { select: { id: true, name: true, projectCode: true } },
},
},
},
orderBy: { dueDate: "asc" },
});
const results: OverdueItem[] = [];
for (const d of deliverables) {
if (!d.dueDate) continue;
const daysOverdue = Math.ceil(
(now.getTime() - startOfDay(d.dueDate).getTime()) / (1000 * 60 * 60 * 24)
);
results.push({
id: d.id,
name: d.name,
type: "deliverable",
dueDate: d.dueDate,
daysOverdue,
status: d.status,
projectId: d.project.id,
projectName: d.project.name,
projectCode: d.project.projectCode,
assignedArtists: [],
});
}
for (const s of stages) {
if (!s.dueDate) continue;
const daysOverdue = Math.ceil(
(now.getTime() - startOfDay(s.dueDate).getTime()) / (1000 * 60 * 60 * 24)
);
results.push({
id: s.id,
name: `${s.template.name}${s.deliverable.name}`,
type: "stage",
dueDate: s.dueDate,
daysOverdue,
status: s.status,
projectId: s.deliverable.project.id,
projectName: s.deliverable.project.name,
projectCode: s.deliverable.project.projectCode,
assignedArtists: s.assignments.map((a) => ({
id: a.user.id,
name: a.user.name || a.user.email,
email: a.user.email,
})),
});
}
// Sort by most overdue first
results.sort((a, b) => b.daysOverdue - a.daysOverdue);
return limit ? results.slice(0, limit) : results;
}
/**
* Generate notifications for approaching and overdue deadlines.
* Should be called periodically (e.g., daily cron or on dashboard load).

View file

@ -119,8 +119,28 @@ export async function getDeliverable(id: string) {
export async function updateDeliverable(
id: string,
data: UpdateDeliverableInput
data: UpdateDeliverableInput,
options: { dryRun?: boolean } = {}
) {
if (options.dryRun) {
const existing = await prisma.deliverable.findUnique({
where: { id },
select: { id: true, name: true },
});
if (!existing) throw new Error("Deliverable not found");
const changedFields = Object.keys(data).filter(
(k) => data[k as keyof typeof data] !== undefined
);
return {
dryRun: true,
action: "update_deliverable",
deliverableId: id,
deliverableName: existing.name,
fieldsToUpdate: changedFields,
};
}
const deliverable = await prisma.deliverable.update({
where: { id },
data,
@ -137,3 +157,137 @@ export async function deleteDeliverable(id: string) {
where: { id },
});
}
// ─── Bulk Operations ────────────────────────────────────
export interface BulkCreateDeliverableItem {
name: string;
priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT";
dueDate?: string;
notes?: string;
cmfSku?: string;
assetCount?: number;
requestedDueDate?: string;
plannedDeliveryDate?: string;
}
export interface BulkCreateDeliverablesResult {
total: number;
created: { id: string; name: string; stageCount: number }[];
failed: { name: string; reason: string }[];
}
/**
* Create multiple deliverables for a project in a single transaction.
* Each deliverable gets all pipeline stages auto-created.
* When dryRun is true, returns a preview without executing.
*/
export async function bulkCreateDeliverables(
projectId: string,
items: BulkCreateDeliverableItem[],
options: { dryRun?: boolean } = {}
): Promise<BulkCreateDeliverablesResult> {
if (items.length === 0) {
return { total: 0, created: [], failed: [] };
}
// Validate project exists
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!project) {
return {
total: items.length,
created: [],
failed: items.map((i) => ({
name: i.name,
reason: "Project not found",
})),
};
}
// Validate items
const valid: BulkCreateDeliverableItem[] = [];
const failed: BulkCreateDeliverablesResult["failed"] = [];
for (const item of items) {
if (!item.name || item.name.trim().length === 0) {
failed.push({ name: item.name || "(empty)", reason: "Name is required" });
continue;
}
valid.push(item);
}
// Fetch pipeline templates
const templates = await prisma.pipelineStageTemplate.findMany({
include: { dependsOn: true },
orderBy: { order: "asc" },
});
if (options.dryRun) {
return {
total: items.length,
created: valid.map((item) => ({
id: "(dry-run)",
name: item.name,
stageCount: templates.length,
})),
failed,
};
}
// Execute in a single transaction
const created: BulkCreateDeliverablesResult["created"] = [];
await prisma.$transaction(async (tx) => {
for (const item of valid) {
const deliverable = await tx.deliverable.create({
data: {
name: item.name,
priority: item.priority || "MEDIUM",
notes: item.notes,
projectId,
dueDate: item.dueDate ? new Date(item.dueDate) : null,
cmfSku: item.cmfSku,
assetCount: item.assetCount,
requestedDueDate: item.requestedDueDate
? new Date(item.requestedDueDate)
: null,
plannedDeliveryDate: item.plannedDeliveryDate
? new Date(item.plannedDeliveryDate)
: null,
},
});
const stageData = templates.map((template) => {
const hasPrerequisites = template.dependsOn.length > 0;
const initialStatus: StageStatus = hasPrerequisites
? "BLOCKED"
: "NOT_STARTED";
return {
deliverableId: deliverable.id,
templateId: template.id,
status: initialStatus,
dueDate: item.dueDate ? new Date(item.dueDate) : null,
};
});
await tx.deliverableStage.createMany({ data: stageData });
created.push({
id: deliverable.id,
name: deliverable.name,
stageCount: templates.length,
});
}
});
// Fire async embedding updates (non-blocking)
for (const c of created) {
updateDeliverableEmbedding(c.id).catch(() => {});
}
updateProjectEmbedding(projectId).catch(() => {});
return { total: items.length, created, failed };
}

View file

@ -31,8 +31,31 @@ export async function getProject(id: string, organizationId: string) {
export async function createProject(
data: CreateProjectInput,
organizationId: string
organizationId: string,
options: { dryRun?: boolean } = {}
) {
// Check for duplicate project code
const existing = await prisma.project.findUnique({
where: { projectCode: data.projectCode },
select: { id: true },
});
if (existing) {
throw new Error(`Project code "${data.projectCode}" already exists`);
}
if (options.dryRun) {
return {
dryRun: true,
action: "create_project",
projectCode: data.projectCode,
name: data.name,
status: data.status,
priority: data.priority,
fieldCount: Object.keys(data).filter((k) => data[k as keyof typeof data] != null).length,
};
}
const project = await prisma.project.create({
data: {
...data,
@ -49,8 +72,31 @@ export async function createProject(
export async function updateProject(
id: string,
data: UpdateProjectInput,
organizationId: string
organizationId: string,
options: { dryRun?: boolean } = {}
) {
// Verify project exists
const existing = await prisma.project.findFirst({
where: { id, organizationId },
select: { id: true, name: true, projectCode: true },
});
if (!existing) throw new Error("Project not found");
if (options.dryRun) {
const changedFields = Object.keys(data).filter(
(k) => data[k as keyof typeof data] !== undefined
);
return {
dryRun: true,
action: "update_project",
projectId: id,
projectName: existing.name,
projectCode: existing.projectCode,
fieldsToUpdate: changedFields,
};
}
const project = await prisma.project.update({
where: { id, organizationId },
data,

View file

@ -143,6 +143,114 @@ export interface ArtistSuggestion {
overallScore: number;
}
/**
* Get all available artists in the organization, ranked by capacity headroom.
* "Available" = currently below max capacity. Optionally filter by skill match.
*/
export async function getAvailableArtists(
organizationId: string,
options: { skillIds?: string[]; departmentFilter?: string } = {}
): Promise<ArtistSuggestion[]> {
const users = await prisma.user.findMany({
where: {
organizationId,
...(options.departmentFilter
? { department: options.departmentFilter }
: {}),
},
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
department: true,
maxCapacity: true,
skills: { include: { skill: true } },
_count: {
select: {
assignments: {
where: {
deliverableStage: {
status: {
in: [
"NOT_STARTED",
"IN_PROGRESS",
"IN_REVIEW",
"CHANGES_REQUESTED",
],
},
},
},
},
},
},
},
orderBy: { name: "asc" },
});
const results: ArtistSuggestion[] = users
.map((user) => {
const activeAssignments = (user._count as any).assignments ?? 0;
const utilizationPercent =
user.maxCapacity > 0
? Math.round((activeAssignments / user.maxCapacity) * 100)
: 100;
// Skill filtering: if skillIds provided, compute match
let skillMatchScore = 50; // neutral if no filter
const matchedSkills: ArtistSuggestion["matchedSkills"] = [];
const missingSkills: ArtistSuggestion["missingSkills"] = [];
if (options.skillIds && options.skillIds.length > 0) {
const userSkillIds = new Set(user.skills.map((us) => us.skillId));
let matched = 0;
for (const sid of options.skillIds) {
const userSkill = user.skills.find((us) => us.skillId === sid);
if (userSkill) {
matched++;
matchedSkills.push({
skillName: userSkill.skill.name,
userLevel: userSkill.level,
importance: 1,
});
} else {
missingSkills.push({ skillName: sid, importance: 1 });
}
}
skillMatchScore = Math.round(
(matched / options.skillIds.length) * 100
);
}
const availabilityScore = Math.max(0, 100 - utilizationPercent);
const overallScore = Math.round(
skillMatchScore * 0.4 + availabilityScore * 0.6
);
return {
userId: user.id,
userName: user.name || user.email,
userEmail: user.email,
userImage: user.image,
role: user.role,
department: user.department,
maxCapacity: user.maxCapacity,
activeAssignments,
utilizationPercent,
skillMatchScore,
matchedSkills,
missingSkills,
overallScore,
};
})
// Only return users with headroom
.filter((u) => u.activeAssignments < u.maxCapacity);
results.sort((a, b) => b.overallScore - a.overallScore);
return results;
}
/**
* Get suggested artists for a given stage template, ranked by skill match and current load.
*/

View file

@ -3,14 +3,274 @@ import { canTransition } from "@/lib/pipeline/stage-machine";
import { canStageStart, getStageIdsToUnblock } from "@/lib/pipeline/dependency-engine";
import type { StageStatus } from "@/generated/prisma/client";
// ─── Query Functions ────────────────────────────────────
export interface BlockedStageInfo {
stageId: string;
stageName: string;
stageSlug: string;
deliverableId: string;
deliverableName: string;
projectId: string;
projectName: string;
projectCode: string;
blockedSince: Date;
waitingOn: { stageName: string; stageSlug: string; status: string }[];
}
/**
* Get all blocked stages across an organization, with details on what they're waiting for.
* Optionally filter by project.
*/
export async function getBlockedStages(
organizationId: string,
options: { projectId?: string } = {}
): Promise<BlockedStageInfo[]> {
const where: any = {
status: "BLOCKED" as StageStatus,
deliverable: {
project: { organizationId },
},
};
if (options.projectId) {
where.deliverable.projectId = options.projectId;
}
const blockedStages = await prisma.deliverableStage.findMany({
where,
include: {
template: {
include: { dependsOn: { include: { prerequisite: true } } },
},
deliverable: {
include: {
project: { select: { id: true, name: true, projectCode: true } },
stages: {
include: { template: true },
},
},
},
},
orderBy: { createdAt: "asc" },
});
return blockedStages.map((stage) => {
// Determine what prerequisites are still incomplete
const waitingOn = stage.template.dependsOn
.map((dep) => {
const prereqStage = stage.deliverable.stages.find(
(s) => s.templateId === dep.prerequisiteId
);
if (!prereqStage) return null;
// Only include prerequisites that aren't yet complete
const isComplete =
prereqStage.status === "APPROVED" ||
prereqStage.status === "DELIVERED" ||
(prereqStage.template.isOptional && prereqStage.status === "SKIPPED");
if (isComplete) return null;
return {
stageName: prereqStage.template.name,
stageSlug: prereqStage.template.slug,
status: prereqStage.status,
};
})
.filter(Boolean) as BlockedStageInfo["waitingOn"];
return {
stageId: stage.id,
stageName: stage.template.name,
stageSlug: stage.template.slug,
deliverableId: stage.deliverableId,
deliverableName: stage.deliverable.name,
projectId: stage.deliverable.project.id,
projectName: stage.deliverable.project.name,
projectCode: stage.deliverable.project.projectCode,
blockedSince: stage.createdAt,
waitingOn,
};
});
}
// ─── Bulk Operations ────────────────────────────────────
export interface BulkStageUpdateItem {
stageId: string;
newStatus: StageStatus;
subStatus?: string | null;
}
export interface BulkStageUpdateResult {
total: number;
succeeded: { stageId: string; stageName: string; newStatus: string }[];
failed: { stageId: string; stageName: string; reason: string }[];
}
/**
* Update multiple stage statuses in a single transaction.
* Each stage is validated independently; failures don't block other updates.
* When dryRun is true, returns what would happen without executing.
*/
export async function bulkUpdateStageStatuses(
items: BulkStageUpdateItem[],
options: { dryRun?: boolean } = {}
): Promise<BulkStageUpdateResult> {
if (items.length === 0) {
return { total: 0, succeeded: [], failed: [] };
}
// Fetch all stages with their dependencies in one query
const stageIds = items.map((i) => i.stageId);
const stages = await prisma.deliverableStage.findMany({
where: { id: { in: stageIds } },
include: {
template: { include: { dependsOn: true } },
deliverable: {
include: {
stages: {
include: {
template: { include: { dependsOn: true } },
},
},
},
},
},
});
const stageMap = new Map(stages.map((s) => [s.id, s]));
const succeeded: BulkStageUpdateResult["succeeded"] = [];
const failed: BulkStageUpdateResult["failed"] = [];
// Validate each item
for (const item of items) {
const stage = stageMap.get(item.stageId);
if (!stage) {
failed.push({
stageId: item.stageId,
stageName: "Unknown",
reason: "Stage not found",
});
continue;
}
// Check transition validity
const transitionCheck = canTransition(stage.status, item.newStatus);
if (!transitionCheck.allowed) {
failed.push({
stageId: item.stageId,
stageName: stage.template.name,
reason: transitionCheck.reason || "Invalid transition",
});
continue;
}
// Check dependencies for IN_PROGRESS
if (item.newStatus === "IN_PROGRESS") {
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.status,
template: {
id: s.template.id,
slug: s.template.slug,
isCriticalGate: s.template.isCriticalGate,
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
}));
const currentStage = allStages.find((s) => s.id === item.stageId)!;
const depCheck = canStageStart(currentStage, allStages);
if (!depCheck.allowed) {
failed.push({
stageId: item.stageId,
stageName: stage.template.name,
reason: depCheck.reason || "Dependencies not met",
});
continue;
}
}
succeeded.push({
stageId: item.stageId,
stageName: stage.template.name,
newStatus: item.newStatus,
});
}
// If dry run, return the preview without executing
if (options.dryRun) {
return { total: items.length, succeeded, failed };
}
// Execute all valid updates in a transaction
if (succeeded.length > 0) {
await prisma.$transaction(async (tx) => {
const now = new Date();
for (const item of succeeded) {
const original = stageMap.get(item.stageId)!;
const matchingInput = items.find((i) => i.stageId === item.stageId)!;
await tx.deliverableStage.update({
where: { id: item.stageId },
data: {
status: matchingInput.newStatus,
subStatus:
matchingInput.subStatus !== undefined
? matchingInput.subStatus
: undefined,
startDate:
matchingInput.newStatus === "IN_PROGRESS" && !original.startDate
? now
: undefined,
completedDate:
matchingInput.newStatus === "APPROVED" ||
matchingInput.newStatus === "DELIVERED"
? now
: undefined,
},
});
// Handle critical gate unblocking
if (
(matchingInput.newStatus === "APPROVED" ||
matchingInput.newStatus === "DELIVERED") &&
original.template.isCriticalGate
) {
const allStages = original.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === item.stageId ? matchingInput.newStatus : s.status,
template: {
id: s.template.id,
slug: s.template.slug,
isCriticalGate: s.template.isCriticalGate,
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
}));
const approvedStage = allStages.find((s) => s.id === item.stageId)!;
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
if (idsToUnblock.length > 0) {
await tx.deliverableStage.updateMany({
where: { id: { in: idsToUnblock } },
data: { status: "NOT_STARTED" },
});
}
}
}
});
}
return { total: items.length, succeeded, failed };
}
/**
* Update a stage's status with dependency enforcement.
* When a critical gate is approved, automatically unblocks downstream stages.
* When dryRun is true, validates and returns a preview without executing.
*/
export async function updateStageStatus(
stageId: string,
newStatus: StageStatus,
subStatus?: string | null
subStatus?: string | null,
options: { dryRun?: boolean } = {}
) {
// Fetch the stage with its template, dependencies, and sibling stages
const stage = await prisma.deliverableStage.findUnique({
@ -63,6 +323,47 @@ export async function updateStageStatus(
}
}
// Build preview of what will happen
const preview = {
stageId,
stageName: stage.template.name,
currentStatus: stage.status,
newStatus,
willUnblockDownstream: false as boolean,
downstreamStageNames: [] as string[],
};
// Check if this will unblock downstream stages
if (
(newStatus === "APPROVED" || newStatus === "DELIVERED") &&
stage.template.isCriticalGate
) {
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === stageId ? newStatus : s.status,
template: {
id: s.template.id,
slug: s.template.slug,
isCriticalGate: s.template.isCriticalGate,
isOptional: s.template.isOptional,
dependsOn: s.template.dependsOn,
},
}));
const approvedStage = allStages.find((s) => s.id === stageId)!;
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
if (idsToUnblock.length > 0) {
preview.willUnblockDownstream = true;
preview.downstreamStageNames = stage.deliverable.stages
.filter((s) => idsToUnblock.includes(s.id))
.map((s) => s.template.name);
}
}
// Dry run: return preview without executing
if (options.dryRun) {
return { success: true, preview };
}
// Perform the update
return prisma.$transaction(async (tx) => {
const now = new Date();