diff --git a/.env.example b/.env.example index 47d617f..b1a50f2 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/CLI_READINESS_STATUS.md b/CLI_READINESS_STATUS.md new file mode 100644 index 0000000..cca0158 --- /dev/null +++ b/CLI_READINESS_STATUS.md @@ -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.1–12.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* diff --git a/assets/temp/Artists-Roles b/assets/temp/Artists-Roles new file mode 100644 index 0000000..3ad413f --- /dev/null +++ b/assets/temp/Artists-Roles @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2cdbe01..6333a43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/db-init.sql b/docker/db-init.sql new file mode 100644 index 0000000..371c24c --- /dev/null +++ b/docker/db-init.sql @@ -0,0 +1,2 @@ +-- Ensure pgvector extension is enabled on database creation +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/docker/ollama-entrypoint.sh b/docker/ollama-entrypoint.sh new file mode 100644 index 0000000..0e0b9d0 --- /dev/null +++ b/docker/ollama-entrypoint.sh @@ -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 diff --git a/prisma/migrations/20260306_add_pgvector/migration.sql b/prisma/migrations/20260306_add_pgvector/migration.sql deleted file mode 100644 index 569b771..0000000 --- a/prisma/migrations/20260306_add_pgvector/migration.sql +++ /dev/null @@ -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; diff --git a/prisma/migrations/20260312152601_init/migration.sql b/prisma/migrations/20260312152601_init/migration.sql new file mode 100644 index 0000000..9c8c29d --- /dev/null +++ b/prisma/migrations/20260312152601_init/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2ce19c..98c94c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/prisma/seed.ts b/prisma/seed.ts index 88f9a6c..78d6013 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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(); @@ -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 = { - "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) diff --git a/src/app/api/automations/[ruleId]/executions/route.ts b/src/app/api/automations/[ruleId]/executions/route.ts new file mode 100644 index 0000000..d8a69c8 --- /dev/null +++ b/src/app/api/automations/[ruleId]/executions/route.ts @@ -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); + } +} diff --git a/src/app/api/automations/[ruleId]/route.ts b/src/app/api/automations/[ruleId]/route.ts new file mode 100644 index 0000000..36afe73 --- /dev/null +++ b/src/app/api/automations/[ruleId]/route.ts @@ -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); + } +} diff --git a/src/app/api/automations/route.ts b/src/app/api/automations/route.ts new file mode 100644 index 0000000..4bbd5f8 --- /dev/null +++ b/src/app/api/automations/route.ts @@ -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); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..24c952a --- /dev/null +++ b/src/app/api/chat/route.ts @@ -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); + } +} diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx new file mode 100644 index 0000000..2bceb04 --- /dev/null +++ b/src/components/chat/chat-panel.tsx @@ -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 ( +
+ {provider === "claude" ? ( + + ) : ( + + )} + {provider === "claude" ? "Claude" : "Ollama (local)"} +
+ ); +} + +function MessageBubble({ message }: { message: ChatMessage }) { + const isUser = message.role === "user"; + + return ( +
+
+ {isUser ? : } +
+ +
+ {message.isLoading ? ( +
+ + Thinking... +
+ ) : ( +
+ {message.content} +
+ )} + + {!isUser && message.provider && !message.isLoading && ( +
+ +
+ )} +
+
+ ); +} + +export function ChatPanel() { + const [open, setOpen] = useState(false); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + const inputRef = useRef(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 ( + + + + + + + {/* Header */} + +
+ + + AI Assistant + +
+ + {messages.length > 0 && ( + + )} +
+
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+ +

How can I help?

+

+ Ask about project status, assign artists, create deliverables, or check what's overdue. +

+
+ {[ + "What's overdue this week?", + "Who has capacity right now?", + "Show me all blocked stages", + ].map((suggestion) => ( + + ))} +
+
+ ) : ( + messages.map((msg) => ) + )} +
+ + {/* Input */} +
+
+ 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 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 5cf58a7..c2678f0 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -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() { runCommand(() => - window.dispatchEvent(new CustomEvent("open-smart-search")) + window.dispatchEvent(new CustomEvent("open-chat")) ) } > - - Smart Search + + AI Assistant - AI-powered + Ask anything diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index f10b2b3..052ec0b 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -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 = { @@ -47,7 +44,6 @@ const NOTIF_ICONS: Record = { }; 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 ( <>
@@ -72,21 +61,8 @@ export function Topbar() {
- {/* Smart Search button */} - - - - - Smart Search - + {/* AI Assistant */} + @@ -202,11 +178,6 @@ export function Topbar() {
- {/* Smart Search Panel — rendered outside header to avoid backdrop-filter containing block */} - setSearchOpen(false)} - /> ); } diff --git a/src/hooks/use-chat.ts b/src/hooks/use-chat.ts new file mode 100644 index 0000000..f0598d9 --- /dev/null +++ b/src/hooks/use-chat.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [provider, setProvider] = useState<"claude" | "ollama" | "none">("none"); + const queryClient = useQueryClient(); + const abortRef = useRef(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, + }; +} diff --git a/src/lib/automation/action-executor.ts b/src/lib/automation/action-executor.ts new file mode 100644 index 0000000..2ebd17a --- /dev/null +++ b/src/lib/automation/action-executor.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/src/lib/automation/event-bus.ts b/src/lib/automation/event-bus.ts new file mode 100644 index 0000000..fee197b --- /dev/null +++ b/src/lib/automation/event-bus.ts @@ -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; +} + +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; + +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 { + 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, + }); +} diff --git a/src/lib/automation/rule-engine.ts b/src/lib/automation/rule-engine.ts new file mode 100644 index 0000000..1876836 --- /dev/null +++ b/src/lib/automation/rule-engine.ts @@ -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 +): 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 }; +} diff --git a/src/lib/chat/provider.ts b/src/lib/chat/provider.ts new file mode 100644 index 0000000..ca257aa --- /dev/null +++ b/src/lib/chat/provider.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + // 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", + }; +} diff --git a/src/lib/chat/tool-definitions.ts b/src/lib/chat/tool-definitions.ts new file mode 100644 index 0000000..62305d4 --- /dev/null +++ b/src/lib/chat/tool-definitions.ts @@ -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; + 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"], + }, + }, +]; diff --git a/src/lib/chat/tool-executor.ts b/src/lib/chat/tool-executor.ts new file mode 100644 index 0000000..6f661d0 --- /dev/null +++ b/src/lib/chat/tool-executor.ts @@ -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 = { + 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, + context: { organizationId: string; userId: string } +): Promise { + 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), + }; + } +} diff --git a/src/lib/services/assignment-service.ts b/src/lib/services/assignment-service.ts index 5a50adc..f0254d1 100644 --- a/src/lib/services/assignment-service.ts +++ b/src/lib/services/assignment-service.ts @@ -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 { + 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 }; +} diff --git a/src/lib/services/automation-service.ts b/src/lib/services/automation-service.ts new file mode 100644 index 0000000..4ca0bf9 --- /dev/null +++ b/src/lib/services/automation-service.ts @@ -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 { + // 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); diff --git a/src/lib/services/deadline-service.ts b/src/lib/services/deadline-service.ts index 4b957ab..3ccb08b 100644 --- a/src/lib/services/deadline-service.ts +++ b/src/lib/services/deadline-service.ts @@ -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 { + 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). diff --git a/src/lib/services/deliverable-service.ts b/src/lib/services/deliverable-service.ts index b253e0f..eb7cec8 100644 --- a/src/lib/services/deliverable-service.ts +++ b/src/lib/services/deliverable-service.ts @@ -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 { + 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 }; +} diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 5a3c0a3..a2856e6 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -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, diff --git a/src/lib/services/skill-service.ts b/src/lib/services/skill-service.ts index 0a5e36a..f065de3 100644 --- a/src/lib/services/skill-service.ts +++ b/src/lib/services/skill-service.ts @@ -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 { + 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. */ diff --git a/src/lib/services/stage-service.ts b/src/lib/services/stage-service.ts index 7d18fb1..7fda95d 100644 --- a/src/lib/services/stage-service.ts +++ b/src/lib/services/stage-service.ts @@ -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 { + 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 { + 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();