feat: Implement automation event bus and rule engine
- Add event bus for dispatching automation events with handlers. - Create rule engine to evaluate events against defined triggers. - Introduce chat provider to interface with Claude API and Ollama fallback. - Define tool schemas for Claude-compatible operations. - Implement tool executor to map tool calls to service layer functions. - Develop automation service for CRUD operations on rules and event handling.
This commit is contained in:
parent
a4b5bbf5c9
commit
e5b398d7da
32 changed files with 4080 additions and 132 deletions
11
.env.example
11
.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"
|
||||
|
|
|
|||
126
CLI_READINESS_STATUS.md
Normal file
126
CLI_READINESS_STATUS.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# CLI Anything — Readiness & Implementation Status
|
||||
|
||||
> Tracks all prerequisite work and CLI Anything implementation progress.
|
||||
> Updated as each item is completed.
|
||||
|
||||
---
|
||||
|
||||
## Phase A: Service Layer Gaps (Prerequisites)
|
||||
|
||||
| # | Item | Status | Date |
|
||||
|---|------|--------|------|
|
||||
| A1 | `getBlockedStages()` — query all blocked stages with dependency info | ✅ Complete | 2026-03-12 |
|
||||
| A2 | `getAvailableArtists()` — filter by capacity + skills org-wide | ✅ Complete | 2026-03-12 |
|
||||
| A3 | `listOverdue()` — flat overdue list with assignee info for AI consumption | ✅ Complete | 2026-03-12 |
|
||||
| A4 | Bulk `createDeliverables()` — batch creation with stage auto-setup | ✅ Complete | 2026-03-12 |
|
||||
| A5 | Bulk `updateStageStatuses()` — batch status updates with validation | ✅ Complete | 2026-03-12 |
|
||||
| A6 | Bulk `assignArtists()` — batch assignments with validation | ✅ Complete | 2026-03-12 |
|
||||
|
||||
## Phase B: Dry-Run / Preview Mode for Mutations
|
||||
|
||||
| # | Item | Status | Date |
|
||||
|---|------|--------|------|
|
||||
| B1 | `dryRun` option on `updateStageStatus()` — shows downstream unblock preview | ✅ Complete | 2026-03-12 |
|
||||
| B2 | `dryRun` option on `assignUserToStage()` — shows action preview | ✅ Complete | 2026-03-12 |
|
||||
| B3 | `dryRun` option on `createProject()` / `updateProject()` / `updateDeliverable()` | ✅ Complete | 2026-03-12 |
|
||||
|
||||
## Phase C: Skills & Capacity Data Population
|
||||
|
||||
| # | Item | Status | Date |
|
||||
|---|------|--------|------|
|
||||
| C1 | Seed default CG pipeline skills (10 skills) | ✅ Complete | Pre-existing in seed.ts |
|
||||
| C2 | Seed stage-skill requirement mappings (all 10 stages) | ✅ Complete | Pre-existing in seed.ts |
|
||||
| C3 | Skills admin UI functional (registry + user assignment) | ✅ Complete | Pre-existing |
|
||||
|
||||
## Phase D: Event Bus & Automation Infrastructure (UPGRADE_PLAN 7.1 foundation)
|
||||
|
||||
| # | Item | Status | Date |
|
||||
|---|------|--------|------|
|
||||
| D1 | `AutomationRule` + `AutomationExecution` + `ChatMessage` Prisma models | ✅ Complete | 2026-03-12 |
|
||||
| D2 | Event bus — typed events for stage/revision/assignment changes | ✅ Complete | 2026-03-12 |
|
||||
| D3 | Rule evaluation engine — condition matching with 9 operators | ✅ Complete | 2026-03-12 |
|
||||
| D4 | Action executor — status update, notify, assign (incl. auto), webhook | ✅ Complete | 2026-03-12 |
|
||||
| D5 | Execution logging for audit trail (SUCCESS/PARTIAL_FAILURE/FAILURE) | ✅ Complete | 2026-03-12 |
|
||||
| D6 | API routes: `/api/automations/`, `[ruleId]/`, `[ruleId]/executions/` | ✅ Complete | 2026-03-12 |
|
||||
|
||||
## Phase E: Docker & Ollama Readiness (UPGRADE_PLAN 12.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*
|
||||
55
assets/temp/Artists-Roles
Normal file
55
assets/temp/Artists-Roles
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
|
||||
Full_Name
|
||||
Role
|
||||
Aditya Varma
|
||||
CGI Stills Artist Senior
|
||||
Ameya Bhagwat
|
||||
CGI Artist (Stills) senior
|
||||
Ameya Kandivkar
|
||||
CGI Artist (Animation)
|
||||
Amit Sharma
|
||||
CGI Artist (Stills) INTERMEDIATE
|
||||
Anantha Krishnan
|
||||
CGI Model Prep Team Lead
|
||||
Ankit Kumar
|
||||
CGI Artist (Stills) senior
|
||||
Ankit Kumar Gupta
|
||||
CGI Animation Artist
|
||||
Arun Prakash
|
||||
Model Prep Artist
|
||||
Babon Ghosh
|
||||
CGI Animation Artist
|
||||
Bharat Bhushan
|
||||
CGI Stills Artist senior
|
||||
Eric Rodriguez
|
||||
CGI Artist stills team lead
|
||||
Hujef Bagwan
|
||||
CGI Animation Artist
|
||||
Ishan Aneja
|
||||
CGI Artist (Stills) INTERMEDIATE
|
||||
Jinesh Thacker
|
||||
CGI Stills Artist Senior
|
||||
Juan Garcia
|
||||
CGI Artist (Stills) Senior
|
||||
Krishna Nand
|
||||
CGI Artist still entry
|
||||
Nijil Rajithan
|
||||
Model Prep Artist
|
||||
Niteen Veer
|
||||
CGI Artist (Animation)
|
||||
Nizam P
|
||||
CGI Stills Artist INTERMEDIATE
|
||||
Pankaj Duragkar
|
||||
CGI Artist (Animation)
|
||||
Prateek Kaushik
|
||||
CGI Stills Team Lead
|
||||
Sandeep Sidhu
|
||||
CGI Animation Team Lead
|
||||
Soham Baviskar
|
||||
CGI model prep Entry
|
||||
Sonu Kumar
|
||||
CGI Animation Artist
|
||||
Xavier Plasso
|
||||
CGI Stills Artist senior
|
||||
Yash Vaidya
|
||||
CGI Artist (Stills) Entry
|
||||
|
|
@ -11,36 +11,61 @@ services:
|
|||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/db-init.sql:/docker-entrypoint-initdb.d/01-pgvector.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Ollama (local AI embeddings) ──────────────────────
|
||||
# ─── Ollama (local AI — embeddings + chat fallback) ────
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/bash", "/entrypoint.sh"]
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
- ./docker/ollama-entrypoint.sh:/entrypoint.sh:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:11434/api/tags || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
# Uncomment for GPU acceleration (requires nvidia-container-toolkit):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
# ─── Next.js app (production only) ─────────────────────
|
||||
# Uncomment for deployment. For local dev, run `npm run dev` directly.
|
||||
# app:
|
||||
# build: .
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
# environment:
|
||||
# DATABASE_URL: postgresql://postgres:postgres@db:5432/hp_prod_tracker?schema=public
|
||||
# OLLAMA_HOST: http://ollama:11434
|
||||
# OLLAMA_EMBED_MODEL: nomic-embed-text
|
||||
# NODE_ENV: production
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# ─── Next.js app (production) ──────────────────────────
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/hp_prod_tracker?schema=public
|
||||
OLLAMA_HOST: http://ollama:11434
|
||||
OLLAMA_EMBED_MODEL: nomic-embed-text
|
||||
OLLAMA_LLM_MODEL: qwen3:1.7b
|
||||
NODE_ENV: production
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
profiles:
|
||||
- production
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
|
|
|||
2
docker/db-init.sql
Normal file
2
docker/db-init.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Ensure pgvector extension is enabled on database creation
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
39
docker/ollama-entrypoint.sh
Normal file
39
docker/ollama-entrypoint.sh
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/bash
|
||||
# Ollama entrypoint — starts the server and pulls required models on first run.
|
||||
# Models are cached in the Docker volume, so subsequent starts skip the pull.
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting Ollama server..."
|
||||
ollama serve &
|
||||
OLLAMA_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for Ollama to be ready..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
echo "Ollama is ready."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Pull required models if not already present
|
||||
pull_if_missing() {
|
||||
local model=$1
|
||||
if ollama list 2>/dev/null | grep -q "$model"; then
|
||||
echo "Model '$model' already available."
|
||||
else
|
||||
echo "Pulling model '$model'... (this may take a few minutes on first run)"
|
||||
ollama pull "$model"
|
||||
echo "Model '$model' pulled successfully."
|
||||
fi
|
||||
}
|
||||
|
||||
pull_if_missing "nomic-embed-text"
|
||||
pull_if_missing "qwen3:1.7b"
|
||||
|
||||
echo "All models ready."
|
||||
|
||||
# Keep the server running
|
||||
wait $OLLAMA_PID
|
||||
|
|
@ -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;
|
||||
493
prisma/migrations/20260312152601_init/migration.sql
Normal file
493
prisma/migrations/20260312152601_init/migration.sql
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
-- Enable pgvector extension (must be before any vector column usage)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'PRODUCER', 'ARTIST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectStatus" AS ENUM ('ACTIVE', 'ON_HOLD', 'COMPLETED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Priority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DeliverableStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'IN_REVIEW', 'APPROVED', 'ON_HOLD');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StageStatus" AS ENUM ('BLOCKED', 'NOT_STARTED', 'IN_PROGRESS', 'IN_REVIEW', 'CHANGES_REQUESTED', 'APPROVED', 'DELIVERED', 'SKIPPED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RevisionStatus" AS ENUM ('SUBMITTED', 'IN_REVIEW', 'CHANGES_REQUESTED', 'APPROVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationType" AS ENUM ('ASSIGNMENT', 'STATUS_CHANGE', 'REVISION_SUBMITTED', 'REVISION_FEEDBACK', 'COMMENT', 'DEADLINE_APPROACHING', 'DEADLINE_OVERDUE', 'STAGE_UNBLOCKED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssignmentRole" AS ENUM ('LEAD', 'SUPPORT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SkillLevel" AS ENUM ('JUNIOR', 'INTERMEDIATE', 'SENIOR', 'LEAD');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExecutionStatus" AS ENUM ('SUCCESS', 'PARTIAL_FAILURE', 'FAILURE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"domain" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
"role" "Role" NOT NULL DEFAULT 'ARTIST',
|
||||
"department" TEXT,
|
||||
"maxCapacity" INTEGER NOT NULL DEFAULT 5,
|
||||
"organizationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pipeline_stage_templates" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"isCriticalGate" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isOptional" BOOLEAN NOT NULL DEFAULT false,
|
||||
"description" TEXT,
|
||||
"estimatedDays" DOUBLE PRECISION,
|
||||
|
||||
CONSTRAINT "pipeline_stage_templates_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pipeline_stage_dependencies" (
|
||||
"id" TEXT NOT NULL,
|
||||
"stageId" TEXT NOT NULL,
|
||||
"prerequisiteId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "pipeline_stage_dependencies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projects" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectCode" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "ProjectStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"priority" "Priority" NOT NULL DEFAULT 'MEDIUM',
|
||||
"startDate" TIMESTAMP(3),
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"businessUnit" TEXT,
|
||||
"formFactor" TEXT,
|
||||
"codeName" TEXT,
|
||||
"npiOrRefresh" TEXT,
|
||||
"quarter" TEXT,
|
||||
"requestor" TEXT,
|
||||
"workfrontId" TEXT,
|
||||
"omgCode" TEXT,
|
||||
"bmtId" TEXT,
|
||||
"estimatedCost" DOUBLE PRECISION,
|
||||
"actualCost" DOUBLE PRECISION,
|
||||
"agency" TEXT,
|
||||
"embedding" vector(768),
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverables" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" "DeliverableStatus" NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"priority" "Priority" NOT NULL DEFAULT 'MEDIUM',
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"cmfSku" TEXT,
|
||||
"assetCount" INTEGER,
|
||||
"requestedDueDate" TIMESTAMP(3),
|
||||
"plannedDeliveryDate" TIMESTAMP(3),
|
||||
"actualDeliveryDate" TIMESTAMP(3),
|
||||
"wfInputDate" TIMESTAMP(3),
|
||||
"embedding" vector(768),
|
||||
"projectId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "deliverables_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deliverable_stages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "StageStatus" NOT NULL DEFAULT 'BLOCKED',
|
||||
"revisionRound" INTEGER NOT NULL DEFAULT 0,
|
||||
"startDate" TIMESTAMP(3),
|
||||
"completedDate" TIMESTAMP(3),
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"notes" TEXT,
|
||||
"subStatus" TEXT,
|
||||
"deliverableId" TEXT NOT NULL,
|
||||
"templateId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "deliverable_stages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stage_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"role" "AssignmentRole" DEFAULT 'LEAD',
|
||||
"deliverableStageId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "stage_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "revisions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roundNumber" INTEGER NOT NULL,
|
||||
"status" "RevisionStatus" NOT NULL DEFAULT 'SUBMITTED',
|
||||
"feedbackNotes" TEXT,
|
||||
"internalNotes" TEXT,
|
||||
"attachments" JSONB,
|
||||
"deliverableStageId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "revisions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"deliverableStageId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "NotificationType" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"link" TEXT,
|
||||
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "skills" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "skills_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_skills" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"skillId" TEXT NOT NULL,
|
||||
"level" "SkillLevel" NOT NULL DEFAULT 'INTERMEDIATE',
|
||||
|
||||
CONSTRAINT "user_skills_pkey" PRIMARY KEY ("userId","skillId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stage_skill_requirements" (
|
||||
"stageTemplateId" TEXT NOT NULL,
|
||||
"skillId" TEXT NOT NULL,
|
||||
"importance" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "stage_skill_requirements_pkey" PRIMARY KEY ("stageTemplateId","skillId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "automation_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"trigger" JSONB NOT NULL,
|
||||
"actions" JSONB NOT NULL,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "automation_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "automation_executions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ruleId" TEXT NOT NULL,
|
||||
"triggeredBy" JSONB NOT NULL,
|
||||
"result" JSONB NOT NULL,
|
||||
"status" "ExecutionStatus" NOT NULL,
|
||||
"error" TEXT,
|
||||
"executedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "automation_executions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"toolCalls" JSONB,
|
||||
"toolResults" JSONB,
|
||||
"metadata" JSONB,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "search_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"query" TEXT NOT NULL,
|
||||
"resultCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"clickedId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "search_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "organizations_domain_key" ON "organizations"("domain");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_provider_providerAccountId_key" ON "accounts"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_sessionToken_key" ON "sessions"("sessionToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pipeline_stage_templates_name_key" ON "pipeline_stage_templates"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pipeline_stage_templates_slug_key" ON "pipeline_stage_templates"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pipeline_stage_templates_order_key" ON "pipeline_stage_templates"("order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pipeline_stage_dependencies_stageId_prerequisiteId_key" ON "pipeline_stage_dependencies"("stageId", "prerequisiteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "projects_projectCode_key" ON "projects"("projectCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_organizationId_idx" ON "projects"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_status_idx" ON "projects"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "deliverables_projectId_idx" ON "deliverables"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "deliverables_status_idx" ON "deliverables"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "deliverable_stages_deliverableId_idx" ON "deliverable_stages"("deliverableId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "deliverable_stages_status_idx" ON "deliverable_stages"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "deliverable_stages_deliverableId_templateId_key" ON "deliverable_stages"("deliverableId", "templateId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stage_assignments_userId_idx" ON "stage_assignments"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stage_assignments_deliverableStageId_userId_key" ON "stage_assignments"("deliverableStageId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "revisions_deliverableStageId_idx" ON "revisions"("deliverableStageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_deliverableStageId_idx" ON "comments"("deliverableStageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notifications_userId_isRead_idx" ON "notifications"("userId", "isRead");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "skills_name_key" ON "skills"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "automation_rules_organizationId_idx" ON "automation_rules"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "automation_rules_isEnabled_idx" ON "automation_rules"("isEnabled");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "automation_executions_ruleId_idx" ON "automation_executions"("ruleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "automation_executions_executedAt_idx" ON "automation_executions"("executedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "chat_messages_sessionId_idx" ON "chat_messages"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "chat_messages_userId_idx" ON "chat_messages"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "search_logs_userId_idx" ON "search_logs"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pipeline_stage_dependencies" ADD CONSTRAINT "pipeline_stage_dependencies_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "pipeline_stage_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pipeline_stage_dependencies" ADD CONSTRAINT "pipeline_stage_dependencies_prerequisiteId_fkey" FOREIGN KEY ("prerequisiteId") REFERENCES "pipeline_stage_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_stages" ADD CONSTRAINT "deliverable_stages_deliverableId_fkey" FOREIGN KEY ("deliverableId") REFERENCES "deliverables"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deliverable_stages" ADD CONSTRAINT "deliverable_stages_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "pipeline_stage_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stage_assignments" ADD CONSTRAINT "stage_assignments_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stage_assignments" ADD CONSTRAINT "stage_assignments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "revisions" ADD CONSTRAINT "revisions_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_deliverableStageId_fkey" FOREIGN KEY ("deliverableStageId") REFERENCES "deliverable_stages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_skills" ADD CONSTRAINT "user_skills_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_skills" ADD CONSTRAINT "user_skills_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "skills"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stage_skill_requirements" ADD CONSTRAINT "stage_skill_requirements_stageTemplateId_fkey" FOREIGN KEY ("stageTemplateId") REFERENCES "pipeline_stage_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stage_skill_requirements" ADD CONSTRAINT "stage_skill_requirements_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "skills"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "automation_rules" ADD CONSTRAINT "automation_rules_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "automation_rules" ADD CONSTRAINT "automation_rules_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "automation_executions" ADD CONSTRAINT "automation_executions_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "automation_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "search_logs" ADD CONSTRAINT "search_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
137
prisma/seed.ts
137
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<string, string>();
|
||||
|
|
@ -348,26 +368,61 @@ async function main() {
|
|||
|
||||
// [userId, [skillName, level]]
|
||||
const USER_SKILLS: [string, [string, "JUNIOR" | "INTERMEDIATE" | "SENIOR" | "LEAD"][]][] = [
|
||||
// Alex Rivera — 3D Modeling specialist
|
||||
["user-artist-001", [["Modeling", "SENIOR"], ["UV Mapping", "SENIOR"], ["Texturing", "INTERMEDIATE"], ["Rigging", "JUNIOR"]]],
|
||||
// Priya Patel — 3D Modeling specialist
|
||||
["user-artist-002", [["Modeling", "LEAD"], ["UV Mapping", "LEAD"], ["Texturing", "SENIOR"], ["Rigging", "INTERMEDIATE"]]],
|
||||
// James O'Brien — Lighting & Rendering
|
||||
["user-artist-003", [["Lighting", "LEAD"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Photography", "JUNIOR"]]],
|
||||
// Yuki Tanaka — Lighting & Rendering
|
||||
["user-artist-004", [["Lighting", "SENIOR"], ["Rendering", "LEAD"], ["Compositing", "INTERMEDIATE"], ["Texturing", "JUNIOR"]]],
|
||||
// Elena Volkov — Compositing
|
||||
["user-artist-005", [["Compositing", "LEAD"], ["Retouching", "SENIOR"], ["Lighting", "INTERMEDIATE"], ["Photography", "INTERMEDIATE"]]],
|
||||
// David Kim — Compositing
|
||||
["user-artist-006", [["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"], ["Lighting", "JUNIOR"], ["Rendering", "INTERMEDIATE"]]],
|
||||
// Aisha Mohammed — Animation
|
||||
["user-artist-007", [["Animation", "LEAD"], ["Rigging", "SENIOR"], ["Rendering", "INTERMEDIATE"], ["Lighting", "JUNIOR"]]],
|
||||
// Carlos Mendez — Animation
|
||||
["user-artist-008", [["Animation", "SENIOR"], ["Rigging", "LEAD"], ["Rendering", "INTERMEDIATE"], ["Modeling", "JUNIOR"]]],
|
||||
// Sophie Laurent — Retouching
|
||||
["user-artist-009", [["Retouching", "LEAD"], ["Photography", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Lighting", "JUNIOR"]]],
|
||||
// Ryan Cooper — 3D Generalist
|
||||
["user-artist-010", [["Modeling", "INTERMEDIATE"], ["Texturing", "INTERMEDIATE"], ["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"], ["Animation", "JUNIOR"]]],
|
||||
// ── CGI Stills ────────────────────────────────────────
|
||||
// Aditya Varma — Senior, Photocomps specialty
|
||||
["user-artist-001", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"], ["Photography", "INTERMEDIATE"]]],
|
||||
// Ameya Bhagwat — Senior
|
||||
["user-artist-002", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
|
||||
// Amit Sharma — Intermediate
|
||||
["user-artist-004", [["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"]]],
|
||||
// Ankit Kumar — Senior
|
||||
["user-artist-006", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
|
||||
// Bharat Bhushan — Senior
|
||||
["user-artist-010", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
|
||||
// Eric Rodriguez — Stills Team Lead
|
||||
["user-artist-011", [["Lighting", "LEAD"], ["Rendering", "SENIOR"], ["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"]]],
|
||||
// Ishan Aneja — Intermediate
|
||||
["user-artist-013", [["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"]]],
|
||||
// Jinesh Thacker — Senior
|
||||
["user-artist-014", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
|
||||
// Juan Garcia — Senior
|
||||
["user-artist-015", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
|
||||
// Krishna Nand — Entry/Junior
|
||||
["user-artist-016", [["Lighting", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Nizam P — Intermediate
|
||||
["user-artist-019", [["Lighting", "INTERMEDIATE"], ["Rendering", "INTERMEDIATE"], ["Compositing", "JUNIOR"]]],
|
||||
// Prateek Kaushik — Stills Team Lead
|
||||
["user-artist-021", [["Lighting", "LEAD"], ["Rendering", "SENIOR"], ["Compositing", "SENIOR"], ["Retouching", "INTERMEDIATE"]]],
|
||||
// Xavier Plasso — Senior
|
||||
["user-artist-025", [["Lighting", "SENIOR"], ["Rendering", "SENIOR"], ["Compositing", "INTERMEDIATE"], ["Retouching", "JUNIOR"]]],
|
||||
// Yash Vaidya — Entry/Junior
|
||||
["user-artist-026", [["Lighting", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// ── CGI Animation ─────────────────────────────────────
|
||||
// Ameya Kandivkar — Intermediate
|
||||
["user-artist-003", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Ankit Kumar Gupta — Intermediate
|
||||
["user-artist-007", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Babon Ghosh — Intermediate
|
||||
["user-artist-009", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Hujef Bagwan — Intermediate
|
||||
["user-artist-012", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Niteen Veer — Intermediate
|
||||
["user-artist-018", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Pankaj Duragkar — Intermediate
|
||||
["user-artist-020", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// Sandeep Sidhu — Animation Team Lead
|
||||
["user-artist-022", [["Animation", "LEAD"], ["Rigging", "SENIOR"], ["Rendering", "INTERMEDIATE"]]],
|
||||
// Sonu Kumar — Intermediate
|
||||
["user-artist-024", [["Animation", "INTERMEDIATE"], ["Rigging", "JUNIOR"], ["Rendering", "JUNIOR"]]],
|
||||
// ── Model Prep ────────────────────────────────────────
|
||||
// Anantha Krishnan — Model Prep Team Lead
|
||||
["user-artist-005", [["Modeling", "LEAD"], ["UV Mapping", "LEAD"], ["Texturing", "SENIOR"]]],
|
||||
// Arun Prakash — Intermediate
|
||||
["user-artist-008", [["Modeling", "INTERMEDIATE"], ["UV Mapping", "INTERMEDIATE"], ["Texturing", "JUNIOR"]]],
|
||||
// Nijil Rajithan — Intermediate
|
||||
["user-artist-017", [["Modeling", "INTERMEDIATE"], ["UV Mapping", "INTERMEDIATE"], ["Texturing", "JUNIOR"]]],
|
||||
// Soham Baviskar — Entry/Junior
|
||||
["user-artist-023", [["Modeling", "JUNIOR"], ["UV Mapping", "JUNIOR"]]],
|
||||
];
|
||||
|
||||
let userSkillCount = 0;
|
||||
|
|
@ -400,14 +455,14 @@ async function main() {
|
|||
|
||||
// Map stages to appropriate artists by stage type
|
||||
const stageToArtists: Record<string, string[]> = {
|
||||
"model-prep": ["user-artist-001", "user-artist-002", "user-artist-010"],
|
||||
"early-images": ["user-artist-003", "user-artist-004"],
|
||||
"catalog-images": ["user-artist-003", "user-artist-004", "user-artist-006"],
|
||||
"hero-images": ["user-artist-003", "user-artist-005", "user-artist-006"],
|
||||
"packaging-images": ["user-artist-004", "user-artist-006"],
|
||||
"photocomps": ["user-artist-005", "user-artist-009"],
|
||||
"360-spin-animations": ["user-artist-007", "user-artist-008"],
|
||||
"dynamic-spin": ["user-artist-007", "user-artist-008"],
|
||||
"model-prep": ["user-artist-005", "user-artist-008", "user-artist-017", "user-artist-023"],
|
||||
"early-images": ["user-artist-002", "user-artist-006", "user-artist-010"],
|
||||
"catalog-images": ["user-artist-011", "user-artist-021", "user-artist-014", "user-artist-015", "user-artist-002"],
|
||||
"hero-images": ["user-artist-011", "user-artist-021", "user-artist-001", "user-artist-002"],
|
||||
"packaging-images": ["user-artist-006", "user-artist-010", "user-artist-013", "user-artist-019"],
|
||||
"photocomps": ["user-artist-001", "user-artist-015", "user-artist-014"],
|
||||
"360-spin-animations": ["user-artist-022", "user-artist-003", "user-artist-009", "user-artist-024"],
|
||||
"dynamic-spin": ["user-artist-022", "user-artist-020", "user-artist-018", "user-artist-007"],
|
||||
};
|
||||
|
||||
// Clear existing assignments (to allow re-runs)
|
||||
|
|
|
|||
21
src/app/api/automations/[ruleId]/executions/route.ts
Normal file
21
src/app/api/automations/[ruleId]/executions/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession, serverError } from "@/lib/api-utils";
|
||||
import { listExecutions } from "@/lib/services/automation-service";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ ruleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { ruleId } = await params;
|
||||
const limit = Number(req.nextUrl.searchParams.get("limit")) || 50;
|
||||
|
||||
const executions = await listExecutions(ruleId, { limit });
|
||||
return NextResponse.json(executions);
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
90
src/app/api/automations/[ruleId]/route.ts
Normal file
90
src/app/api/automations/[ruleId]/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession, notFound, serverError } from "@/lib/api-utils";
|
||||
import {
|
||||
getAutomationRule,
|
||||
updateAutomationRule,
|
||||
deleteAutomationRule,
|
||||
} from "@/lib/services/automation-service";
|
||||
import { validateTrigger } from "@/lib/automation/rule-engine";
|
||||
import { validateActions } from "@/lib/automation/action-executor";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ ruleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { ruleId } = await params;
|
||||
const rule = await getAutomationRule(ruleId);
|
||||
|
||||
if (!rule) return notFound("Rule not found");
|
||||
|
||||
return NextResponse.json(rule);
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ ruleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
if (session!.user.role === "ARTIST") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { ruleId } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
if (body.trigger) {
|
||||
const tv = validateTrigger(body.trigger);
|
||||
if (!tv.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid trigger", details: tv.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.actions) {
|
||||
const av = validateActions(body.actions);
|
||||
if (!av.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid actions", details: av.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rule = await updateAutomationRule(ruleId, body);
|
||||
return NextResponse.json(rule);
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ ruleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
if (session!.user.role === "ARTIST") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { ruleId } = await params;
|
||||
await deleteAutomationRule(ruleId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
71
src/app/api/automations/route.ts
Normal file
71
src/app/api/automations/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
|
||||
import {
|
||||
listAutomationRules,
|
||||
createAutomationRule,
|
||||
} from "@/lib/services/automation-service";
|
||||
import { validateTrigger } from "@/lib/automation/rule-engine";
|
||||
import { validateActions } from "@/lib/automation/action-executor";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const rules = await listAutomationRules(session!.user.organizationId!);
|
||||
return NextResponse.json(rules);
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
// Only producers and admins can create rules
|
||||
if (session!.user.role === "ARTIST") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, description, trigger, actions, isEnabled } = body;
|
||||
|
||||
if (!name || !trigger || !actions) {
|
||||
return badRequest("name, trigger, and actions are required");
|
||||
}
|
||||
|
||||
// Validate trigger
|
||||
const triggerValidation = validateTrigger(trigger);
|
||||
if (!triggerValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid trigger", details: triggerValidation.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate actions
|
||||
const actionsValidation = validateActions(actions);
|
||||
if (!actionsValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid actions", details: actionsValidation.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const rule = await createAutomationRule({
|
||||
name,
|
||||
description,
|
||||
organizationId: session!.user.organizationId!,
|
||||
createdById: session!.user.id,
|
||||
trigger,
|
||||
actions,
|
||||
isEnabled,
|
||||
});
|
||||
|
||||
return NextResponse.json(rule, { status: 201 });
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
96
src/app/api/chat/route.ts
Normal file
96
src/app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession, serverError } from "@/lib/api-utils";
|
||||
import { chat, getProviderStatus } from "@/lib/chat/provider";
|
||||
import { executeTool } from "@/lib/chat/tool-executor";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { messages, context } = await req.json();
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "messages array is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session!.user.id;
|
||||
const organizationId = session!.user.organizationId!;
|
||||
|
||||
// Send to AI provider (Claude → Ollama fallback)
|
||||
let response = await chat(messages);
|
||||
|
||||
// Collect all cache keys to invalidate
|
||||
const allInvalidateKeys: string[] = [];
|
||||
|
||||
// If the AI wants to call tools, execute them and continue the conversation
|
||||
let iterations = 0;
|
||||
const MAX_ITERATIONS = 5; // prevent runaway tool loops
|
||||
|
||||
while (response.toolCalls.length > 0 && iterations < MAX_ITERATIONS) {
|
||||
iterations++;
|
||||
|
||||
const toolResults: { tool_use_id: string; content: string }[] = [];
|
||||
|
||||
for (const toolCall of response.toolCalls) {
|
||||
const result = await executeTool(toolCall.name, toolCall.input, {
|
||||
organizationId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Track cache keys for the frontend
|
||||
if (result.invalidateKeys.length > 0) {
|
||||
allInvalidateKeys.push(...result.invalidateKeys);
|
||||
}
|
||||
|
||||
toolResults.push({
|
||||
tool_use_id: toolCall.id,
|
||||
content: JSON.stringify(
|
||||
result.success
|
||||
? result.data
|
||||
: { error: result.error }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Build the full conversation including the assistant's tool calls and the results
|
||||
const updatedMessages = [
|
||||
...messages,
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: response.content || "(tool use)",
|
||||
},
|
||||
];
|
||||
|
||||
// Continue conversation with tool results
|
||||
response = await chat(updatedMessages, toolResults);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
content: response.content,
|
||||
provider: response.provider,
|
||||
invalidateKeys: [...new Set(allInvalidateKeys)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Chat API]", error);
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/chat — returns provider health status.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const status = await getProviderStatus();
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return serverError(error);
|
||||
}
|
||||
}
|
||||
247
src/components/chat/chat-panel.tsx
Normal file
247
src/components/chat/chat-panel.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
MessageSquare,
|
||||
X,
|
||||
Send,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Server,
|
||||
StopCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useChat, type ChatMessage } from "@/hooks/use-chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ProviderBadge({ provider }: { provider: "claude" | "ollama" | "none" }) {
|
||||
if (provider === "none") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
provider === "claude"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
|
||||
)}
|
||||
>
|
||||
{provider === "claude" ? (
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<Server className="h-2.5 w-2.5" />
|
||||
)}
|
||||
{provider === "claude" ? "Claude" : "Ollama (local)"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", isUser ? "flex-row-reverse" : "flex-row")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||||
isUser
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
|
||||
isUser
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "bg-[var(--muted)]"
|
||||
)}
|
||||
>
|
||||
{message.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span className="text-xs text-[var(--muted-foreground)]">Thinking...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUser && message.provider && !message.isLoading && (
|
||||
<div className="mt-1">
|
||||
<ProviderBadge provider={message.provider} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { messages, isLoading, provider, sendMessage, clearMessages, cancelRequest } =
|
||||
useChat();
|
||||
|
||||
// Listen for custom event from command palette
|
||||
useEffect(() => {
|
||||
const handleOpen = () => setOpen(true);
|
||||
window.addEventListener("open-chat", handleOpen);
|
||||
return () => window.removeEventListener("open-chat", handleOpen);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Focus input when panel opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 200);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage(input);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-9 w-9"
|
||||
title="AI Assistant"
|
||||
>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{provider !== "none" && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-[var(--background)]",
|
||||
provider === "claude" ? "bg-purple-500" : "bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col p-0 sm:max-w-[420px]"
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="border-b px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Bot className="h-4 w-4" />
|
||||
AI Assistant
|
||||
</SheetTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProviderBadge provider={provider} />
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={clearMessages}
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Bot className="h-10 w-10 text-[var(--muted-foreground)] mb-3" />
|
||||
<p className="text-sm font-medium">How can I help?</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1 max-w-[260px]">
|
||||
Ask about project status, assign artists, create deliverables, or check what's overdue.
|
||||
</p>
|
||||
<div className="mt-4 space-y-1.5 w-full max-w-[280px]">
|
||||
{[
|
||||
"What's overdue this week?",
|
||||
"Who has capacity right now?",
|
||||
"Show me all blocked stages",
|
||||
].map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
className="w-full rounded-lg border px-3 py-2 text-left text-xs hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => {
|
||||
setInput(suggestion);
|
||||
sendMessage(suggestion);
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => <MessageBubble key={msg.id} message={msg} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t px-4 py-3">
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask anything..."
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-lg border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--ring)] disabled:opacity-50"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={cancelRequest}
|
||||
title="Stop"
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,9 +11,7 @@ import {
|
|||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
FileSpreadsheet,
|
||||
Search,
|
||||
Sparkles,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
|
|
@ -66,14 +64,14 @@ export function CommandPalette() {
|
|||
<CommandItem
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
window.dispatchEvent(new CustomEvent("open-smart-search"))
|
||||
window.dispatchEvent(new CustomEvent("open-chat"))
|
||||
)
|
||||
}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Smart Search
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI Assistant
|
||||
<span className="ml-auto text-[10px] text-[var(--muted-foreground)]">
|
||||
AI-powered
|
||||
Ask anything
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
|
|
@ -13,7 +12,6 @@ import {
|
|||
AlertTriangle,
|
||||
Unlock,
|
||||
Clock,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
|
|
@ -25,14 +23,13 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useUnreadCount,
|
||||
useNotifications,
|
||||
useMarkAsRead,
|
||||
useMarkAllAsRead,
|
||||
} from "@/hooks/use-notifications";
|
||||
import { SmartSearchPanel } from "@/components/search/smart-search-panel";
|
||||
import { ChatPanel } from "@/components/chat/chat-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NOTIF_ICONS: Record<string, typeof Bell> = {
|
||||
|
|
@ -47,7 +44,6 @@ const NOTIF_ICONS: Record<string, typeof Bell> = {
|
|||
};
|
||||
|
||||
export function Topbar() {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const { data: countData } = useUnreadCount();
|
||||
const { data: notifications } = useNotifications(10);
|
||||
const markRead = useMarkAsRead();
|
||||
|
|
@ -56,13 +52,6 @@ export function Topbar() {
|
|||
const unreadCount = countData?.count ?? 0;
|
||||
const items = (notifications as any[]) ?? [];
|
||||
|
||||
// Listen for custom event from command palette
|
||||
useEffect(() => {
|
||||
const handleOpenSearch = () => setSearchOpen(true);
|
||||
window.addEventListener("open-smart-search", handleOpenSearch);
|
||||
return () => window.removeEventListener("open-smart-search", handleOpenSearch);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)]/80 backdrop-blur-sm px-4 md:px-5 sticky top-0 z-10" role="banner">
|
||||
|
|
@ -72,21 +61,8 @@ export function Topbar() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Smart Search button */}
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label="Smart Search"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Smart Search</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* AI Assistant */}
|
||||
<ChatPanel />
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
|
|
@ -202,11 +178,6 @@ export function Topbar() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{/* Smart Search Panel — rendered outside header to avoid backdrop-filter containing block */}
|
||||
<SmartSearchPanel
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
138
src/hooks/use-chat.ts
Normal file
138
src/hooks/use-chat.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
provider?: "claude" | "ollama";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [provider, setProvider] = useState<"claude" | "ollama" | "none">("none");
|
||||
const queryClient = useQueryClient();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
// Add user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Add placeholder for assistant response
|
||||
const loadingMsg: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: new Date(),
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg, loadingMsg]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Build message history for the API
|
||||
const apiMessages = [...messages, userMsg].map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: apiMessages }),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorData?.error || `Chat request failed: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update the loading message with the actual response
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === loadingMsg.id
|
||||
? {
|
||||
...m,
|
||||
content: data.content,
|
||||
provider: data.provider,
|
||||
isLoading: false,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
// Update provider indicator
|
||||
if (data.provider) {
|
||||
setProvider(data.provider);
|
||||
}
|
||||
|
||||
// Invalidate TanStack Query caches for any mutated data
|
||||
if (data.invalidateKeys && data.invalidateKeys.length > 0) {
|
||||
for (const key of data.invalidateKeys) {
|
||||
queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
// Also invalidate broader dashboard/timeline queries
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AbortError") return;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === loadingMsg.id
|
||||
? {
|
||||
...m,
|
||||
content: `Sorry, I encountered an error: ${(error as Error).message}`,
|
||||
isLoading: false,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[messages, isLoading, queryClient]
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setMessages((prev) => prev.filter((m) => !m.isLoading));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
provider,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
cancelRequest,
|
||||
};
|
||||
}
|
||||
338
src/lib/automation/action-executor.ts
Normal file
338
src/lib/automation/action-executor.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* Action Executor — runs the actions defined in an automation rule.
|
||||
*
|
||||
* Action format (JSON stored in AutomationRule.actions):
|
||||
* [
|
||||
* { type: "update_stage_status", params: { status: "NOT_STARTED" } },
|
||||
* { type: "send_notification", params: { title: "...", message: "...", roles: ["PRODUCER"] } },
|
||||
* { type: "create_assignment", params: { userId: "auto" } }
|
||||
* ]
|
||||
*
|
||||
* Each action returns a result object describing what happened.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { AutomationEvent } from "./event-bus";
|
||||
import { createNotifications } from "@/lib/services/notification-service";
|
||||
|
||||
export interface ActionDefinition {
|
||||
type: "update_stage_status" | "send_notification" | "create_assignment" | "send_webhook";
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
actionType: string;
|
||||
success: boolean;
|
||||
detail: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single action in the context of a triggered event.
|
||||
*/
|
||||
async function executeAction(
|
||||
action: ActionDefinition,
|
||||
event: AutomationEvent
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
switch (action.type) {
|
||||
case "update_stage_status":
|
||||
return await executeUpdateStageStatus(action, event);
|
||||
case "send_notification":
|
||||
return await executeSendNotification(action, event);
|
||||
case "create_assignment":
|
||||
return await executeCreateAssignment(action, event);
|
||||
case "send_webhook":
|
||||
return await executeSendWebhook(action, event);
|
||||
default:
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: `Unknown action type: ${action.type}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Action threw an error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: Update a stage's status.
|
||||
* Uses stageId from the event payload or params.targetStageId.
|
||||
*/
|
||||
async function executeUpdateStageStatus(
|
||||
action: ActionDefinition,
|
||||
event: AutomationEvent
|
||||
): Promise<ActionResult> {
|
||||
const stageId = action.params.stageId || event.payload.stageId;
|
||||
const newStatus = action.params.status;
|
||||
|
||||
if (!stageId || !newStatus) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Missing stageId or status in params",
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.deliverableStage.update({
|
||||
where: { id: stageId },
|
||||
data: { status: newStatus },
|
||||
});
|
||||
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: true,
|
||||
detail: `Stage ${stageId} updated to ${newStatus}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: Send a notification to specified users or roles.
|
||||
*/
|
||||
async function executeSendNotification(
|
||||
action: ActionDefinition,
|
||||
event: AutomationEvent
|
||||
): Promise<ActionResult> {
|
||||
const { title, message, roles, userIds } = action.params;
|
||||
|
||||
if (!title || !message) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Missing title or message",
|
||||
};
|
||||
}
|
||||
|
||||
// Determine target users
|
||||
let targetUserIds: string[] = userIds || [];
|
||||
|
||||
if (roles && Array.isArray(roles) && roles.length > 0) {
|
||||
const roleUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
organizationId: event.organizationId,
|
||||
role: { in: roles },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
targetUserIds = [
|
||||
...targetUserIds,
|
||||
...roleUsers.map((u) => u.id),
|
||||
];
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
targetUserIds = [...new Set(targetUserIds)];
|
||||
|
||||
if (targetUserIds.length === 0) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: true,
|
||||
detail: "No target users found — notification skipped",
|
||||
};
|
||||
}
|
||||
|
||||
// Interpolate event data into title/message
|
||||
const interpolated = (str: string) =>
|
||||
str.replace(/\{(\w+)\}/g, (_, key) => event.payload[key] ?? `{${key}}`);
|
||||
|
||||
await createNotifications(
|
||||
targetUserIds.map((userId) => ({
|
||||
userId,
|
||||
type: "STATUS_CHANGE" as const,
|
||||
title: interpolated(title),
|
||||
message: interpolated(message),
|
||||
link: action.params.link
|
||||
? interpolated(action.params.link)
|
||||
: undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: true,
|
||||
detail: `Notification sent to ${targetUserIds.length} user(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: Auto-assign an artist to a stage.
|
||||
* If userId is "auto", uses the skill-based suggestion system.
|
||||
*/
|
||||
async function executeCreateAssignment(
|
||||
action: ActionDefinition,
|
||||
event: AutomationEvent
|
||||
): Promise<ActionResult> {
|
||||
const stageId = action.params.stageId || event.payload.stageId;
|
||||
let userId = action.params.userId;
|
||||
|
||||
if (!stageId) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Missing stageId",
|
||||
};
|
||||
}
|
||||
|
||||
// "auto" mode: pick the best available artist using skill matching
|
||||
if (userId === "auto") {
|
||||
const stage = await prisma.deliverableStage.findUnique({
|
||||
where: { id: stageId },
|
||||
select: { templateId: true },
|
||||
});
|
||||
|
||||
if (!stage) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Stage not found for auto-assignment",
|
||||
};
|
||||
}
|
||||
|
||||
// Import dynamically to avoid circular deps
|
||||
const { getSuggestedArtists } = await import(
|
||||
"@/lib/services/skill-service"
|
||||
);
|
||||
const suggestions = await getSuggestedArtists(
|
||||
stage.templateId,
|
||||
event.organizationId
|
||||
);
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "No available artists for auto-assignment",
|
||||
};
|
||||
}
|
||||
|
||||
userId = suggestions[0].userId;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Missing userId",
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.stageAssignment.upsert({
|
||||
where: {
|
||||
deliverableStageId_userId: {
|
||||
deliverableStageId: stageId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
deliverableStageId: stageId,
|
||||
userId,
|
||||
role: action.params.role || "LEAD",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: true,
|
||||
detail: `Assigned user ${userId} to stage ${stageId}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: POST to an external webhook URL.
|
||||
*/
|
||||
async function executeSendWebhook(
|
||||
action: ActionDefinition,
|
||||
event: AutomationEvent
|
||||
): Promise<ActionResult> {
|
||||
const { url } = action.params;
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Missing webhook URL",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: event.type,
|
||||
payload: event.payload,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000), // 10s timeout
|
||||
});
|
||||
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: response.ok,
|
||||
detail: `Webhook ${response.ok ? "succeeded" : "failed"}: ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
actionType: action.type,
|
||||
success: false,
|
||||
detail: "Webhook request failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all actions for a rule and return collected results.
|
||||
*/
|
||||
export async function executeActions(
|
||||
actions: ActionDefinition[],
|
||||
event: AutomationEvent
|
||||
): Promise<ActionResult[]> {
|
||||
const results: ActionResult[] = [];
|
||||
|
||||
for (const action of actions) {
|
||||
const result = await executeAction(action, event);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an action definition (for rule creation UI).
|
||||
*/
|
||||
export function validateActions(
|
||||
actions: any
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!Array.isArray(actions)) {
|
||||
return { valid: false, errors: ["Actions must be an array"] };
|
||||
}
|
||||
|
||||
const validTypes = [
|
||||
"update_stage_status",
|
||||
"send_notification",
|
||||
"create_assignment",
|
||||
"send_webhook",
|
||||
];
|
||||
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
const a = actions[i];
|
||||
if (!validTypes.includes(a.type)) {
|
||||
errors.push(`Action ${i}: invalid type "${a.type}"`);
|
||||
}
|
||||
if (!a.params || typeof a.params !== "object") {
|
||||
errors.push(`Action ${i}: missing params`);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
130
src/lib/automation/event-bus.ts
Normal file
130
src/lib/automation/event-bus.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Event Bus — dispatches events to the automation rule engine.
|
||||
*
|
||||
* Supported events:
|
||||
* stage.status_changed — stage status was updated
|
||||
* revision.submitted — new revision uploaded
|
||||
* assignment.created — artist assigned to stage
|
||||
* deadline.approaching — stage/deliverable due soon
|
||||
* deadline.passed — stage/deliverable overdue
|
||||
*/
|
||||
|
||||
export type AutomationEventType =
|
||||
| "stage.status_changed"
|
||||
| "revision.submitted"
|
||||
| "assignment.created"
|
||||
| "deadline.approaching"
|
||||
| "deadline.passed";
|
||||
|
||||
export interface AutomationEvent {
|
||||
type: AutomationEventType;
|
||||
organizationId: string;
|
||||
timestamp: Date;
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StageStatusChangedPayload {
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
stageSlug: string;
|
||||
deliverableId: string;
|
||||
deliverableName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
isCriticalGate: boolean;
|
||||
}
|
||||
|
||||
export interface RevisionSubmittedPayload {
|
||||
revisionId: string;
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
deliverableId: string;
|
||||
deliverableName: string;
|
||||
projectId: string;
|
||||
roundNumber: number;
|
||||
}
|
||||
|
||||
export interface AssignmentCreatedPayload {
|
||||
assignmentId: string;
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
deliverableId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type EventHandler = (event: AutomationEvent) => Promise<void>;
|
||||
|
||||
const handlers: EventHandler[] = [];
|
||||
|
||||
/**
|
||||
* Register a handler to be called when any automation event fires.
|
||||
*/
|
||||
export function onAutomationEvent(handler: EventHandler) {
|
||||
handlers.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to all registered handlers.
|
||||
* Errors in handlers are caught and logged — they don't block the caller.
|
||||
*/
|
||||
export async function dispatchEvent(event: AutomationEvent): Promise<void> {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
await handler(event);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[EventBus] Handler error for ${event.type}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: create and dispatch a stage.status_changed event.
|
||||
*/
|
||||
export function emitStageStatusChanged(
|
||||
organizationId: string,
|
||||
payload: StageStatusChangedPayload
|
||||
) {
|
||||
return dispatchEvent({
|
||||
type: "stage.status_changed",
|
||||
organizationId,
|
||||
timestamp: new Date(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: create and dispatch a revision.submitted event.
|
||||
*/
|
||||
export function emitRevisionSubmitted(
|
||||
organizationId: string,
|
||||
payload: RevisionSubmittedPayload
|
||||
) {
|
||||
return dispatchEvent({
|
||||
type: "revision.submitted",
|
||||
organizationId,
|
||||
timestamp: new Date(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: create and dispatch an assignment.created event.
|
||||
*/
|
||||
export function emitAssignmentCreated(
|
||||
organizationId: string,
|
||||
payload: AssignmentCreatedPayload
|
||||
) {
|
||||
return dispatchEvent({
|
||||
type: "assignment.created",
|
||||
organizationId,
|
||||
timestamp: new Date(),
|
||||
payload,
|
||||
});
|
||||
}
|
||||
139
src/lib/automation/rule-engine.ts
Normal file
139
src/lib/automation/rule-engine.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Rule Evaluation Engine — matches automation events against stored rules.
|
||||
*
|
||||
* Trigger format (JSON stored in AutomationRule.trigger):
|
||||
* {
|
||||
* event: "stage.status_changed",
|
||||
* conditions: [
|
||||
* { field: "newStatus", operator: "equals", value: "APPROVED" },
|
||||
* { field: "stageSlug", operator: "in", value: ["catalog-images", "model-prep"] },
|
||||
* { field: "isCriticalGate", operator: "equals", value: true }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Supported operators: equals, not_equals, in, not_in, contains, gt, lt, gte, lte
|
||||
*/
|
||||
|
||||
import type { AutomationEvent } from "./event-bus";
|
||||
|
||||
export interface TriggerCondition {
|
||||
field: string;
|
||||
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "gt" | "lt" | "gte" | "lte";
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface TriggerDefinition {
|
||||
event: string;
|
||||
conditions: TriggerCondition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single condition matches against the event payload.
|
||||
*/
|
||||
function evaluateCondition(
|
||||
condition: TriggerCondition,
|
||||
payload: Record<string, any>
|
||||
): boolean {
|
||||
const fieldValue = payload[condition.field];
|
||||
|
||||
switch (condition.operator) {
|
||||
case "equals":
|
||||
return fieldValue === condition.value;
|
||||
case "not_equals":
|
||||
return fieldValue !== condition.value;
|
||||
case "in":
|
||||
return Array.isArray(condition.value) && condition.value.includes(fieldValue);
|
||||
case "not_in":
|
||||
return Array.isArray(condition.value) && !condition.value.includes(fieldValue);
|
||||
case "contains":
|
||||
return typeof fieldValue === "string" && fieldValue.includes(condition.value);
|
||||
case "gt":
|
||||
return typeof fieldValue === "number" && fieldValue > condition.value;
|
||||
case "lt":
|
||||
return typeof fieldValue === "number" && fieldValue < condition.value;
|
||||
case "gte":
|
||||
return typeof fieldValue === "number" && fieldValue >= condition.value;
|
||||
case "lte":
|
||||
return typeof fieldValue === "number" && fieldValue <= condition.value;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event matches a rule's trigger definition.
|
||||
* All conditions must match (AND logic).
|
||||
*/
|
||||
export function matchesRule(
|
||||
event: AutomationEvent,
|
||||
trigger: TriggerDefinition
|
||||
): boolean {
|
||||
// Event type must match
|
||||
if (event.type !== trigger.event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All conditions must pass
|
||||
for (const condition of trigger.conditions) {
|
||||
if (!evaluateCondition(condition, event.payload)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a trigger definition (for rule creation UI).
|
||||
*/
|
||||
export function validateTrigger(
|
||||
trigger: any
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!trigger || typeof trigger !== "object") {
|
||||
return { valid: false, errors: ["Trigger must be an object"] };
|
||||
}
|
||||
|
||||
const validEvents = [
|
||||
"stage.status_changed",
|
||||
"revision.submitted",
|
||||
"assignment.created",
|
||||
"deadline.approaching",
|
||||
"deadline.passed",
|
||||
];
|
||||
|
||||
if (!validEvents.includes(trigger.event)) {
|
||||
errors.push(
|
||||
`Invalid event type "${trigger.event}". Must be one of: ${validEvents.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(trigger.conditions)) {
|
||||
errors.push("conditions must be an array");
|
||||
} else {
|
||||
const validOperators = [
|
||||
"equals",
|
||||
"not_equals",
|
||||
"in",
|
||||
"not_in",
|
||||
"contains",
|
||||
"gt",
|
||||
"lt",
|
||||
"gte",
|
||||
"lte",
|
||||
];
|
||||
for (let i = 0; i < trigger.conditions.length; i++) {
|
||||
const c = trigger.conditions[i];
|
||||
if (!c.field) errors.push(`Condition ${i}: missing field`);
|
||||
if (!validOperators.includes(c.operator)) {
|
||||
errors.push(
|
||||
`Condition ${i}: invalid operator "${c.operator}"`
|
||||
);
|
||||
}
|
||||
if (c.value === undefined) errors.push(`Condition ${i}: missing value`);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
253
src/lib/chat/provider.ts
Normal file
253
src/lib/chat/provider.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Chat Provider — abstracts Claude API vs Ollama fallback.
|
||||
* Default: Claude API. Falls back to Ollama if Claude is unreachable.
|
||||
*/
|
||||
|
||||
import { TOOL_DEFINITIONS } from "./tool-definitions";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
content: string;
|
||||
toolCalls: ToolCall[];
|
||||
provider: "claude" | "ollama";
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are an AI assistant for the HP CG Production Tracker — a tool used by producers to manage CG rendering projects for HP products.
|
||||
|
||||
You help producers by:
|
||||
- Answering questions about project status, deadlines, and workload
|
||||
- Creating and updating projects, deliverables, and pipeline stages
|
||||
- Assigning artists based on skills and availability
|
||||
- Flagging overdue items and bottlenecks
|
||||
|
||||
Important rules:
|
||||
1. For any MUTATION (create, update, assign, delete), ALWAYS call the tool with dryRun=true first to show the user what will happen. Then ask for confirmation before executing with dryRun=false.
|
||||
2. Be concise and specific — producers are busy.
|
||||
3. When showing data, format it in a scannable way (bullet points, tables).
|
||||
4. If you don't have enough information, ask for clarification.
|
||||
5. Reference projects by name or code, not internal IDs, when talking to the user.`;
|
||||
|
||||
/**
|
||||
* Check if Claude API is reachable.
|
||||
*/
|
||||
export async function checkClaudeHealth(): Promise<boolean> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.ok || response.status === 400; // 400 means API is reachable but request was bad
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Ollama is available with a chat-capable model.
|
||||
*/
|
||||
export async function checkOllamaHealth(): Promise<boolean> {
|
||||
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
|
||||
try {
|
||||
const response = await fetch(`${host}/api/tags`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat request to Claude API.
|
||||
*/
|
||||
async function chatWithClaude(
|
||||
messages: ChatMessage[],
|
||||
toolResults?: { tool_use_id: string; content: string }[]
|
||||
): Promise<ChatResponse> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not configured");
|
||||
|
||||
// Build Claude-format messages
|
||||
const claudeMessages: any[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") continue; // system goes in system param
|
||||
claudeMessages.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
|
||||
// Add tool results if provided
|
||||
if (toolResults && toolResults.length > 0) {
|
||||
claudeMessages.push({
|
||||
role: "user",
|
||||
content: toolResults.map((tr) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: tr.tool_use_id,
|
||||
content: tr.content,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 4096,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: claudeMessages,
|
||||
tools: TOOL_DEFINITIONS,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Claude API error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Extract text content and tool calls
|
||||
let content = "";
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
for (const block of result.content) {
|
||||
if (block.type === "text") {
|
||||
content += block.text;
|
||||
} else if (block.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
toolCalls,
|
||||
provider: "claude",
|
||||
stopReason: result.stop_reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat request to local Ollama (fallback).
|
||||
* Note: Ollama's tool calling is less reliable than Claude's.
|
||||
*/
|
||||
async function chatWithOllama(
|
||||
messages: ChatMessage[]
|
||||
): Promise<ChatResponse> {
|
||||
const host = process.env.OLLAMA_HOST || "http://localhost:11434";
|
||||
const model = process.env.OLLAMA_LLM_MODEL || "qwen3:1.7b";
|
||||
|
||||
// For Ollama, include the system prompt and tool descriptions in the prompt
|
||||
const ollamaMessages = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
];
|
||||
|
||||
const response = await fetch(`${host}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: ollamaMessages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3 },
|
||||
}),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama error: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
content: result.message?.content || "I'm having trouble processing that request.",
|
||||
toolCalls: [], // Ollama fallback doesn't use structured tool calling
|
||||
provider: "ollama",
|
||||
stopReason: "end_turn",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat request — tries Claude first, falls back to Ollama.
|
||||
*/
|
||||
export async function chat(
|
||||
messages: ChatMessage[],
|
||||
toolResults?: { tool_use_id: string; content: string }[]
|
||||
): Promise<ChatResponse> {
|
||||
// Try Claude first
|
||||
const hasClaudeKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (hasClaudeKey) {
|
||||
try {
|
||||
return await chatWithClaude(messages, toolResults);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[ChatProvider] Claude failed, falling back to Ollama:",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Ollama
|
||||
try {
|
||||
return await chatWithOllama(messages);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Both Claude and Ollama are unavailable. Claude: ${hasClaudeKey ? "key set but failed" : "no API key"}. Ollama: ${error instanceof Error ? error.message : "unreachable"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current provider status for the UI indicator.
|
||||
*/
|
||||
export async function getProviderStatus(): Promise<{
|
||||
primary: { name: "claude"; available: boolean };
|
||||
fallback: { name: "ollama"; available: boolean };
|
||||
activeProvider: "claude" | "ollama" | "none";
|
||||
}> {
|
||||
const [claudeOk, ollamaOk] = await Promise.all([
|
||||
checkClaudeHealth(),
|
||||
checkOllamaHealth(),
|
||||
]);
|
||||
|
||||
return {
|
||||
primary: { name: "claude", available: claudeOk },
|
||||
fallback: { name: "ollama", available: ollamaOk },
|
||||
activeProvider: claudeOk ? "claude" : ollamaOk ? "ollama" : "none",
|
||||
};
|
||||
}
|
||||
322
src/lib/chat/tool-definitions.ts
Normal file
322
src/lib/chat/tool-definitions.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* Tool Definitions — maps our service layer to Claude-compatible tool schemas.
|
||||
* Each tool has a name, description, and input_schema (JSON Schema).
|
||||
* These are passed to the Claude API's `tools` parameter.
|
||||
*/
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
// ─── Read Operations ────────────────────────────────
|
||||
{
|
||||
name: "list_projects",
|
||||
description:
|
||||
"List all projects in the organization. Returns project names, codes, statuses, priorities, and deliverable counts.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"],
|
||||
description: "Filter by project status (optional)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_project",
|
||||
description:
|
||||
"Get detailed information about a specific project including all its deliverables and pipeline stages.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "The project ID" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_deliverables",
|
||||
description:
|
||||
"List all deliverables for a specific project with their stage statuses and artist assignments.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "The project ID" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_blocked_stages",
|
||||
description:
|
||||
"Get all pipeline stages that are currently blocked, showing what prerequisites they're waiting on. Optionally filter by project.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: {
|
||||
type: "string",
|
||||
description: "Filter to a specific project (optional)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_overdue",
|
||||
description:
|
||||
"Get all overdue deliverables and stages sorted by most overdue first, including assigned artist info.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: {
|
||||
type: "string",
|
||||
description: "Filter to a specific project (optional)",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max items to return (optional, default all)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_available_artists",
|
||||
description:
|
||||
"Get artists who have capacity (below their max concurrent assignments). Optionally filter by department or skills.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
departmentFilter: {
|
||||
type: "string",
|
||||
description: "Filter by department name (optional)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_workload",
|
||||
description:
|
||||
"Get workload data for all team members showing assignments per week, utilization, and capacity.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
numWeeks: {
|
||||
type: "number",
|
||||
description: "Number of weeks to show (default 8)",
|
||||
},
|
||||
projectId: {
|
||||
type: "string",
|
||||
description: "Filter to a specific project (optional)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_suggested_artists",
|
||||
description:
|
||||
"Get artist suggestions for a specific stage based on skill match and current workload.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageTemplateId: {
|
||||
type: "string",
|
||||
description: "The pipeline stage template ID",
|
||||
},
|
||||
},
|
||||
required: ["stageTemplateId"],
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Mutation Operations (require confirmation) ─────
|
||||
{
|
||||
name: "create_project",
|
||||
description:
|
||||
"Create a new project. Use dryRun=true first to preview what will be created, then confirm with dryRun=false.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectCode: { type: "string", description: "Unique project code (e.g., HP-2026-Q3-001)" },
|
||||
name: { type: "string", description: "Project name" },
|
||||
status: { type: "string", enum: ["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"], description: "Project status" },
|
||||
priority: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "URGENT"], description: "Priority level" },
|
||||
description: { type: "string", description: "Project description (optional)" },
|
||||
businessUnit: { type: "string", description: "Business unit (optional)" },
|
||||
quarter: { type: "string", description: "Fiscal quarter e.g. FY26Q3 (optional)" },
|
||||
dueDate: { type: "string", description: "Due date ISO string (optional)" },
|
||||
dryRun: { type: "boolean", description: "If true, preview only — don't actually create" },
|
||||
},
|
||||
required: ["projectCode", "name", "status", "priority"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_deliverable",
|
||||
description:
|
||||
"Create a new deliverable under a project. Auto-creates all 10 pipeline stages. Use dryRun=true to preview.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "The project ID" },
|
||||
name: { type: "string", description: "Deliverable name" },
|
||||
priority: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "URGENT"] },
|
||||
dueDate: { type: "string", description: "Due date ISO string (optional)" },
|
||||
notes: { type: "string", description: "Notes (optional)" },
|
||||
assetCount: { type: "number", description: "Number of assets (optional)" },
|
||||
dryRun: { type: "boolean", description: "If true, preview only" },
|
||||
},
|
||||
required: ["projectId", "name", "priority"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bulk_create_deliverables",
|
||||
description:
|
||||
"Create multiple deliverables at once for a project. Use dryRun=true to preview.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "The project ID" },
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
priority: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "URGENT"] },
|
||||
dueDate: { type: "string" },
|
||||
assetCount: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
description: "Array of deliverables to create",
|
||||
},
|
||||
dryRun: { type: "boolean", description: "If true, preview only" },
|
||||
},
|
||||
required: ["projectId", "items"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "advance_stage",
|
||||
description:
|
||||
"Update a pipeline stage's status. Validates transitions and dependencies. Use dryRun=true to preview side effects (e.g., unblocking downstream stages).",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageId: { type: "string", description: "The deliverable stage ID" },
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["NOT_STARTED", "IN_PROGRESS", "IN_REVIEW", "CHANGES_REQUESTED", "APPROVED", "DELIVERED", "SKIPPED"],
|
||||
description: "New status",
|
||||
},
|
||||
subStatus: { type: "string", description: "Optional sub-status (e.g., 'Clays posted', 'Lighting WIP')" },
|
||||
dryRun: { type: "boolean", description: "If true, preview only" },
|
||||
},
|
||||
required: ["stageId", "status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bulk_update_stages",
|
||||
description:
|
||||
"Update multiple stage statuses at once. Each is validated independently. Use dryRun=true to preview.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageId: { type: "string" },
|
||||
newStatus: { type: "string" },
|
||||
subStatus: { type: "string" },
|
||||
},
|
||||
required: ["stageId", "newStatus"],
|
||||
},
|
||||
},
|
||||
dryRun: { type: "boolean" },
|
||||
},
|
||||
required: ["items"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "assign_artist",
|
||||
description:
|
||||
"Assign an artist to a pipeline stage. Use dryRun=true to preview.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageId: { type: "string", description: "The deliverable stage ID" },
|
||||
userId: { type: "string", description: "The user ID to assign" },
|
||||
role: { type: "string", enum: ["LEAD", "SUPPORT"], description: "Assignment role (default LEAD)" },
|
||||
dryRun: { type: "boolean" },
|
||||
},
|
||||
required: ["stageId", "userId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_assignment",
|
||||
description: "Remove an artist's assignment from a stage.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageId: { type: "string", description: "The deliverable stage ID" },
|
||||
userId: { type: "string", description: "The user ID to remove" },
|
||||
},
|
||||
required: ["stageId", "userId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bulk_assign_artists",
|
||||
description:
|
||||
"Assign multiple artists to stages at once. Use dryRun=true to preview.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
deliverableStageId: { type: "string" },
|
||||
userId: { type: "string" },
|
||||
role: { type: "string", enum: ["LEAD", "SUPPORT"] },
|
||||
},
|
||||
required: ["deliverableStageId", "userId"],
|
||||
},
|
||||
},
|
||||
dryRun: { type: "boolean" },
|
||||
},
|
||||
required: ["items"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_revision",
|
||||
description: "Log a new revision round on a stage.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageId: { type: "string", description: "The deliverable stage ID" },
|
||||
feedbackNotes: { type: "string", description: "Feedback notes (optional)" },
|
||||
internalNotes: { type: "string", description: "Internal notes (optional)" },
|
||||
},
|
||||
required: ["stageId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_revisions",
|
||||
description: "List all revision rounds for a specific stage.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stageId: { type: "string", description: "The deliverable stage ID" },
|
||||
},
|
||||
required: ["stageId"],
|
||||
},
|
||||
},
|
||||
];
|
||||
234
src/lib/chat/tool-executor.ts
Normal file
234
src/lib/chat/tool-executor.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* Tool Executor — maps tool names to service layer calls.
|
||||
* Called by the chat API when Claude invokes a tool.
|
||||
*/
|
||||
|
||||
import { listProjects, getProject, createProject } from "@/lib/services/project-service";
|
||||
import {
|
||||
listDeliverables,
|
||||
createDeliverable,
|
||||
bulkCreateDeliverables,
|
||||
} from "@/lib/services/deliverable-service";
|
||||
import {
|
||||
updateStageStatus,
|
||||
getBlockedStages,
|
||||
bulkUpdateStageStatuses,
|
||||
} from "@/lib/services/stage-service";
|
||||
import {
|
||||
assignUserToStage,
|
||||
removeAssignment,
|
||||
bulkAssignArtists,
|
||||
} from "@/lib/services/assignment-service";
|
||||
import { listOverdue } from "@/lib/services/deadline-service";
|
||||
import { getAvailableArtists, getSuggestedArtists } from "@/lib/services/skill-service";
|
||||
import { getWorkloadData } from "@/lib/services/workload-service";
|
||||
import { createRevision, listRevisions } from "@/lib/services/revision-service";
|
||||
|
||||
/** Tools that mutate data — the chat system should flag these for confirmation */
|
||||
export const MUTATION_TOOLS = new Set([
|
||||
"create_project",
|
||||
"create_deliverable",
|
||||
"bulk_create_deliverables",
|
||||
"advance_stage",
|
||||
"bulk_update_stages",
|
||||
"assign_artist",
|
||||
"remove_assignment",
|
||||
"bulk_assign_artists",
|
||||
"create_revision",
|
||||
]);
|
||||
|
||||
/** Cache keys that should be invalidated after a tool execution */
|
||||
export const CACHE_INVALIDATION_MAP: Record<string, string[]> = {
|
||||
create_project: ["projects"],
|
||||
create_deliverable: ["projects", "deliverables"],
|
||||
bulk_create_deliverables: ["projects", "deliverables"],
|
||||
advance_stage: ["deliverables", "stages", "timeline"],
|
||||
bulk_update_stages: ["deliverables", "stages", "timeline"],
|
||||
assign_artist: ["assignments", "workload", "my-work"],
|
||||
remove_assignment: ["assignments", "workload", "my-work"],
|
||||
bulk_assign_artists: ["assignments", "workload", "my-work"],
|
||||
create_revision: ["revisions", "stages"],
|
||||
};
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
success: boolean;
|
||||
data: any;
|
||||
isMutation: boolean;
|
||||
isDryRun: boolean;
|
||||
invalidateKeys: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by name with the given input.
|
||||
*/
|
||||
export async function executeTool(
|
||||
toolName: string,
|
||||
input: Record<string, any>,
|
||||
context: { organizationId: string; userId: string }
|
||||
): Promise<ToolExecutionResult> {
|
||||
const isMutation = MUTATION_TOOLS.has(toolName);
|
||||
const isDryRun = !!input.dryRun;
|
||||
const invalidateKeys =
|
||||
!isDryRun && isMutation ? CACHE_INVALIDATION_MAP[toolName] || [] : [];
|
||||
|
||||
try {
|
||||
let data: any;
|
||||
|
||||
switch (toolName) {
|
||||
// ── Read Operations ──
|
||||
case "list_projects": {
|
||||
const projects = await listProjects(context.organizationId);
|
||||
// Filter by status if provided
|
||||
data = input.status
|
||||
? projects.filter((p: any) => p.status === input.status)
|
||||
: projects;
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_project": {
|
||||
data = await getProject(input.projectId, context.organizationId);
|
||||
if (!data) throw new Error("Project not found");
|
||||
break;
|
||||
}
|
||||
|
||||
case "list_deliverables": {
|
||||
data = await listDeliverables(input.projectId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_blocked_stages": {
|
||||
data = await getBlockedStages(context.organizationId, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "list_overdue": {
|
||||
data = await listOverdue(context.organizationId, {
|
||||
projectId: input.projectId,
|
||||
limit: input.limit,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_available_artists": {
|
||||
data = await getAvailableArtists(context.organizationId, {
|
||||
departmentFilter: input.departmentFilter,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_workload": {
|
||||
data = await getWorkloadData(context.organizationId, {
|
||||
numWeeks: input.numWeeks,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_suggested_artists": {
|
||||
data = await getSuggestedArtists(
|
||||
input.stageTemplateId,
|
||||
context.organizationId
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Mutation Operations ──
|
||||
case "create_project": {
|
||||
const { dryRun, ...projectData } = input;
|
||||
data = await createProject(
|
||||
projectData as any,
|
||||
context.organizationId,
|
||||
{ dryRun }
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "create_deliverable": {
|
||||
const { projectId, dryRun, ...delivData } = input;
|
||||
if (dryRun) {
|
||||
data = await bulkCreateDeliverables(projectId, [delivData as any], { dryRun: true });
|
||||
} else {
|
||||
data = await createDeliverable(projectId, delivData as any);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "bulk_create_deliverables": {
|
||||
const { projectId, items, dryRun } = input;
|
||||
data = await bulkCreateDeliverables(projectId, items, { dryRun });
|
||||
break;
|
||||
}
|
||||
|
||||
case "advance_stage": {
|
||||
const { stageId, status, subStatus, dryRun } = input;
|
||||
data = await updateStageStatus(stageId, status, subStatus, { dryRun });
|
||||
break;
|
||||
}
|
||||
|
||||
case "bulk_update_stages": {
|
||||
const { items, dryRun } = input;
|
||||
data = await bulkUpdateStageStatuses(items, { dryRun });
|
||||
break;
|
||||
}
|
||||
|
||||
case "assign_artist": {
|
||||
const { stageId, userId, role, dryRun } = input;
|
||||
data = await assignUserToStage(stageId, userId, role || "LEAD", {
|
||||
dryRun,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove_assignment": {
|
||||
data = await removeAssignment(input.stageId, input.userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "bulk_assign_artists": {
|
||||
const { items, dryRun } = input;
|
||||
data = await bulkAssignArtists(items, { dryRun });
|
||||
break;
|
||||
}
|
||||
|
||||
case "create_revision": {
|
||||
data = await createRevision(
|
||||
input.stageId,
|
||||
{
|
||||
feedbackNotes: input.feedbackNotes,
|
||||
internalNotes: input.internalNotes,
|
||||
} as any
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "list_revisions": {
|
||||
data = await listRevisions(input.stageId);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
isMutation: false,
|
||||
isDryRun: false,
|
||||
invalidateKeys: [],
|
||||
error: `Unknown tool: ${toolName}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data, isMutation, isDryRun, invalidateKeys };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
isMutation,
|
||||
isDryRun,
|
||||
invalidateKeys: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,39 @@ import type { AssignmentRole } from "@/generated/prisma/client";
|
|||
export async function assignUserToStage(
|
||||
deliverableStageId: string,
|
||||
userId: string,
|
||||
role: AssignmentRole = "LEAD"
|
||||
role: AssignmentRole = "LEAD",
|
||||
options: { dryRun?: boolean } = {}
|
||||
) {
|
||||
// Validate stage and user exist
|
||||
const [stage, user] = await Promise.all([
|
||||
prisma.deliverableStage.findUnique({
|
||||
where: { id: deliverableStageId },
|
||||
include: { template: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, name: true, email: true, maxCapacity: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!stage) throw new Error("Stage not found");
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
// Check for existing assignment
|
||||
const existing = await prisma.stageAssignment.findUnique({
|
||||
where: { deliverableStageId_userId: { deliverableStageId, userId } },
|
||||
});
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
dryRun: true,
|
||||
action: existing ? "update_role" : "create_assignment",
|
||||
stageName: stage.template.name,
|
||||
userName: user.name || user.email,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
return prisma.stageAssignment.upsert({
|
||||
where: {
|
||||
deliverableStageId_userId: { deliverableStageId, userId },
|
||||
|
|
@ -45,3 +76,119 @@ export async function getMyWork(userId: string) {
|
|||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bulk Operations ────────────────────────────────────
|
||||
|
||||
export interface BulkAssignItem {
|
||||
deliverableStageId: string;
|
||||
userId: string;
|
||||
role?: AssignmentRole;
|
||||
}
|
||||
|
||||
export interface BulkAssignResult {
|
||||
total: number;
|
||||
succeeded: {
|
||||
deliverableStageId: string;
|
||||
stageName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
}[];
|
||||
failed: {
|
||||
deliverableStageId: string;
|
||||
userId: string;
|
||||
reason: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign multiple artists to stages in a single transaction.
|
||||
* When dryRun is true, validates and returns preview without executing.
|
||||
*/
|
||||
export async function bulkAssignArtists(
|
||||
items: BulkAssignItem[],
|
||||
options: { dryRun?: boolean } = {}
|
||||
): Promise<BulkAssignResult> {
|
||||
if (items.length === 0) {
|
||||
return { total: 0, succeeded: [], failed: [] };
|
||||
}
|
||||
|
||||
// Fetch all referenced stages and users in bulk
|
||||
const stageIds = [...new Set(items.map((i) => i.deliverableStageId))];
|
||||
const userIds = [...new Set(items.map((i) => i.userId))];
|
||||
|
||||
const [stages, users] = await Promise.all([
|
||||
prisma.deliverableStage.findMany({
|
||||
where: { id: { in: stageIds } },
|
||||
include: { template: true },
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const stageMap = new Map(stages.map((s) => [s.id, s]));
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const succeeded: BulkAssignResult["succeeded"] = [];
|
||||
const failed: BulkAssignResult["failed"] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const stage = stageMap.get(item.deliverableStageId);
|
||||
if (!stage) {
|
||||
failed.push({
|
||||
deliverableStageId: item.deliverableStageId,
|
||||
userId: item.userId,
|
||||
reason: "Stage not found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const user = userMap.get(item.userId);
|
||||
if (!user) {
|
||||
failed.push({
|
||||
deliverableStageId: item.deliverableStageId,
|
||||
userId: item.userId,
|
||||
reason: "User not found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
succeeded.push({
|
||||
deliverableStageId: item.deliverableStageId,
|
||||
stageName: stage.template.name,
|
||||
userId: item.userId,
|
||||
userName: user.name || user.email,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return { total: items.length, succeeded, failed };
|
||||
}
|
||||
|
||||
// Execute all valid assignments in a transaction
|
||||
if (succeeded.length > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
if (!stageMap.has(item.deliverableStageId) || !userMap.has(item.userId))
|
||||
continue;
|
||||
await tx.stageAssignment.upsert({
|
||||
where: {
|
||||
deliverableStageId_userId: {
|
||||
deliverableStageId: item.deliverableStageId,
|
||||
userId: item.userId,
|
||||
},
|
||||
},
|
||||
update: { role: item.role || "LEAD" },
|
||||
create: {
|
||||
deliverableStageId: item.deliverableStageId,
|
||||
userId: item.userId,
|
||||
role: item.role || "LEAD",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { total: items.length, succeeded, failed };
|
||||
}
|
||||
|
|
|
|||
181
src/lib/services/automation-service.ts
Normal file
181
src/lib/services/automation-service.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Automation Service — CRUD for rules + wiring the event bus to rule evaluation + action execution.
|
||||
*
|
||||
* This is the main integration point that connects:
|
||||
* event-bus.ts → rule-engine.ts → action-executor.ts
|
||||
*
|
||||
* On startup (import), it registers itself as an event bus handler.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
onAutomationEvent,
|
||||
type AutomationEvent,
|
||||
} from "@/lib/automation/event-bus";
|
||||
import { matchesRule, type TriggerDefinition } from "@/lib/automation/rule-engine";
|
||||
import {
|
||||
executeActions,
|
||||
type ActionDefinition,
|
||||
type ActionResult,
|
||||
} from "@/lib/automation/action-executor";
|
||||
|
||||
// ─── Rule CRUD ──────────────────────────────────────────
|
||||
|
||||
export async function listAutomationRules(organizationId: string) {
|
||||
return prisma.automationRule.findMany({
|
||||
where: { organizationId },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAutomationRule(ruleId: string) {
|
||||
return prisma.automationRule.findUnique({
|
||||
where: { id: ruleId },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
executions: {
|
||||
orderBy: { executedAt: "desc" },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAutomationRule(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
organizationId: string;
|
||||
createdById: string;
|
||||
trigger: TriggerDefinition;
|
||||
actions: ActionDefinition[];
|
||||
isEnabled?: boolean;
|
||||
}) {
|
||||
return prisma.automationRule.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
organizationId: data.organizationId,
|
||||
createdById: data.createdById,
|
||||
trigger: data.trigger as any,
|
||||
actions: data.actions as any,
|
||||
isEnabled: data.isEnabled ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAutomationRule(
|
||||
ruleId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
trigger?: TriggerDefinition;
|
||||
actions?: ActionDefinition[];
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
) {
|
||||
return prisma.automationRule.update({
|
||||
where: { id: ruleId },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.description !== undefined
|
||||
? { description: data.description }
|
||||
: {}),
|
||||
...(data.trigger !== undefined
|
||||
? { trigger: data.trigger as any }
|
||||
: {}),
|
||||
...(data.actions !== undefined
|
||||
? { actions: data.actions as any }
|
||||
: {}),
|
||||
...(data.isEnabled !== undefined ? { isEnabled: data.isEnabled } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAutomationRule(ruleId: string) {
|
||||
return prisma.automationRule.delete({
|
||||
where: { id: ruleId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listExecutions(
|
||||
ruleId: string,
|
||||
options: { limit?: number } = {}
|
||||
) {
|
||||
return prisma.automationExecution.findMany({
|
||||
where: { ruleId },
|
||||
orderBy: { executedAt: "desc" },
|
||||
take: options.limit || 50,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Execution Logging ──────────────────────────────────
|
||||
|
||||
async function logExecution(
|
||||
ruleId: string,
|
||||
event: AutomationEvent,
|
||||
results: ActionResult[]
|
||||
) {
|
||||
const allSucceeded = results.every((r) => r.success);
|
||||
const anySucceeded = results.some((r) => r.success);
|
||||
|
||||
const status = allSucceeded
|
||||
? "SUCCESS"
|
||||
: anySucceeded
|
||||
? "PARTIAL_FAILURE"
|
||||
: "FAILURE";
|
||||
|
||||
const errors = results
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => r.error || r.detail)
|
||||
.join("; ");
|
||||
|
||||
await prisma.automationExecution.create({
|
||||
data: {
|
||||
ruleId,
|
||||
triggeredBy: {
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
},
|
||||
result: results as any,
|
||||
status,
|
||||
error: errors || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Event Handler (registered on import) ───────────────
|
||||
|
||||
/**
|
||||
* Core handler: when an event fires, find matching rules and execute them.
|
||||
*/
|
||||
async function handleAutomationEvent(event: AutomationEvent): Promise<void> {
|
||||
// Fetch all enabled rules for this organization
|
||||
const rules = await prisma.automationRule.findMany({
|
||||
where: {
|
||||
organizationId: event.organizationId,
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const rule of rules) {
|
||||
const trigger = rule.trigger as unknown as TriggerDefinition;
|
||||
const actions = rule.actions as unknown as ActionDefinition[];
|
||||
|
||||
// Check if this event matches the rule
|
||||
if (!matchesRule(event, trigger)) continue;
|
||||
|
||||
// Execute all actions
|
||||
const results = await executeActions(actions, event);
|
||||
|
||||
// Log the execution
|
||||
await logExecution(rule.id, event, results);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the handler with the event bus
|
||||
onAutomationEvent(handleAutomationEvent);
|
||||
|
|
@ -142,6 +142,128 @@ export async function checkDeadlines(
|
|||
return { approaching, overdue };
|
||||
}
|
||||
|
||||
// ─── Flat Overdue List (for CLI / AI queries) ───────────
|
||||
|
||||
export interface OverdueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "deliverable" | "stage";
|
||||
dueDate: Date;
|
||||
daysOverdue: number;
|
||||
status: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectCode: string;
|
||||
assignedArtists: { id: string; name: string; email: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a flat, sorted list of all overdue items across the organization.
|
||||
* Unlike checkDeadlines(), this returns assignee info and is optimized for
|
||||
* AI agent consumption / direct display.
|
||||
*/
|
||||
export async function listOverdue(
|
||||
organizationId: string,
|
||||
options: { projectId?: string; limit?: number } = {}
|
||||
): Promise<OverdueItem[]> {
|
||||
const now = startOfDay(new Date());
|
||||
const { projectId, limit } = options;
|
||||
|
||||
const projectFilter: any = { organizationId };
|
||||
if (projectId) projectFilter.id = projectId;
|
||||
|
||||
// Overdue deliverables
|
||||
const deliverables = await prisma.deliverable.findMany({
|
||||
where: {
|
||||
project: projectFilter,
|
||||
dueDate: { lt: now },
|
||||
status: { notIn: ["APPROVED", "ON_HOLD"] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
dueDate: true,
|
||||
status: true,
|
||||
project: { select: { id: true, name: true, projectCode: true } },
|
||||
},
|
||||
orderBy: { dueDate: "asc" },
|
||||
});
|
||||
|
||||
// Overdue stages (with assignments)
|
||||
const stages = await prisma.deliverableStage.findMany({
|
||||
where: {
|
||||
deliverable: { project: projectFilter },
|
||||
dueDate: { lt: now },
|
||||
status: { notIn: ["APPROVED", "DELIVERED", "SKIPPED"] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
dueDate: true,
|
||||
status: true,
|
||||
template: { select: { name: true } },
|
||||
assignments: {
|
||||
select: { user: { select: { id: true, name: true, email: true } } },
|
||||
},
|
||||
deliverable: {
|
||||
select: {
|
||||
name: true,
|
||||
project: { select: { id: true, name: true, projectCode: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { dueDate: "asc" },
|
||||
});
|
||||
|
||||
const results: OverdueItem[] = [];
|
||||
|
||||
for (const d of deliverables) {
|
||||
if (!d.dueDate) continue;
|
||||
const daysOverdue = Math.ceil(
|
||||
(now.getTime() - startOfDay(d.dueDate).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
results.push({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: "deliverable",
|
||||
dueDate: d.dueDate,
|
||||
daysOverdue,
|
||||
status: d.status,
|
||||
projectId: d.project.id,
|
||||
projectName: d.project.name,
|
||||
projectCode: d.project.projectCode,
|
||||
assignedArtists: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of stages) {
|
||||
if (!s.dueDate) continue;
|
||||
const daysOverdue = Math.ceil(
|
||||
(now.getTime() - startOfDay(s.dueDate).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
results.push({
|
||||
id: s.id,
|
||||
name: `${s.template.name} — ${s.deliverable.name}`,
|
||||
type: "stage",
|
||||
dueDate: s.dueDate,
|
||||
daysOverdue,
|
||||
status: s.status,
|
||||
projectId: s.deliverable.project.id,
|
||||
projectName: s.deliverable.project.name,
|
||||
projectCode: s.deliverable.project.projectCode,
|
||||
assignedArtists: s.assignments.map((a) => ({
|
||||
id: a.user.id,
|
||||
name: a.user.name || a.user.email,
|
||||
email: a.user.email,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by most overdue first
|
||||
results.sort((a, b) => b.daysOverdue - a.daysOverdue);
|
||||
|
||||
return limit ? results.slice(0, limit) : results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate notifications for approaching and overdue deadlines.
|
||||
* Should be called periodically (e.g., daily cron or on dashboard load).
|
||||
|
|
|
|||
|
|
@ -119,8 +119,28 @@ export async function getDeliverable(id: string) {
|
|||
|
||||
export async function updateDeliverable(
|
||||
id: string,
|
||||
data: UpdateDeliverableInput
|
||||
data: UpdateDeliverableInput,
|
||||
options: { dryRun?: boolean } = {}
|
||||
) {
|
||||
if (options.dryRun) {
|
||||
const existing = await prisma.deliverable.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!existing) throw new Error("Deliverable not found");
|
||||
|
||||
const changedFields = Object.keys(data).filter(
|
||||
(k) => data[k as keyof typeof data] !== undefined
|
||||
);
|
||||
return {
|
||||
dryRun: true,
|
||||
action: "update_deliverable",
|
||||
deliverableId: id,
|
||||
deliverableName: existing.name,
|
||||
fieldsToUpdate: changedFields,
|
||||
};
|
||||
}
|
||||
|
||||
const deliverable = await prisma.deliverable.update({
|
||||
where: { id },
|
||||
data,
|
||||
|
|
@ -137,3 +157,137 @@ export async function deleteDeliverable(id: string) {
|
|||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bulk Operations ────────────────────────────────────
|
||||
|
||||
export interface BulkCreateDeliverableItem {
|
||||
name: string;
|
||||
priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT";
|
||||
dueDate?: string;
|
||||
notes?: string;
|
||||
cmfSku?: string;
|
||||
assetCount?: number;
|
||||
requestedDueDate?: string;
|
||||
plannedDeliveryDate?: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateDeliverablesResult {
|
||||
total: number;
|
||||
created: { id: string; name: string; stageCount: number }[];
|
||||
failed: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple deliverables for a project in a single transaction.
|
||||
* Each deliverable gets all pipeline stages auto-created.
|
||||
* When dryRun is true, returns a preview without executing.
|
||||
*/
|
||||
export async function bulkCreateDeliverables(
|
||||
projectId: string,
|
||||
items: BulkCreateDeliverableItem[],
|
||||
options: { dryRun?: boolean } = {}
|
||||
): Promise<BulkCreateDeliverablesResult> {
|
||||
if (items.length === 0) {
|
||||
return { total: 0, created: [], failed: [] };
|
||||
}
|
||||
|
||||
// Validate project exists
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) {
|
||||
return {
|
||||
total: items.length,
|
||||
created: [],
|
||||
failed: items.map((i) => ({
|
||||
name: i.name,
|
||||
reason: "Project not found",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate items
|
||||
const valid: BulkCreateDeliverableItem[] = [];
|
||||
const failed: BulkCreateDeliverablesResult["failed"] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.name || item.name.trim().length === 0) {
|
||||
failed.push({ name: item.name || "(empty)", reason: "Name is required" });
|
||||
continue;
|
||||
}
|
||||
valid.push(item);
|
||||
}
|
||||
|
||||
// Fetch pipeline templates
|
||||
const templates = await prisma.pipelineStageTemplate.findMany({
|
||||
include: { dependsOn: true },
|
||||
orderBy: { order: "asc" },
|
||||
});
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
total: items.length,
|
||||
created: valid.map((item) => ({
|
||||
id: "(dry-run)",
|
||||
name: item.name,
|
||||
stageCount: templates.length,
|
||||
})),
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
// Execute in a single transaction
|
||||
const created: BulkCreateDeliverablesResult["created"] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const item of valid) {
|
||||
const deliverable = await tx.deliverable.create({
|
||||
data: {
|
||||
name: item.name,
|
||||
priority: item.priority || "MEDIUM",
|
||||
notes: item.notes,
|
||||
projectId,
|
||||
dueDate: item.dueDate ? new Date(item.dueDate) : null,
|
||||
cmfSku: item.cmfSku,
|
||||
assetCount: item.assetCount,
|
||||
requestedDueDate: item.requestedDueDate
|
||||
? new Date(item.requestedDueDate)
|
||||
: null,
|
||||
plannedDeliveryDate: item.plannedDeliveryDate
|
||||
? new Date(item.plannedDeliveryDate)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
const stageData = templates.map((template) => {
|
||||
const hasPrerequisites = template.dependsOn.length > 0;
|
||||
const initialStatus: StageStatus = hasPrerequisites
|
||||
? "BLOCKED"
|
||||
: "NOT_STARTED";
|
||||
return {
|
||||
deliverableId: deliverable.id,
|
||||
templateId: template.id,
|
||||
status: initialStatus,
|
||||
dueDate: item.dueDate ? new Date(item.dueDate) : null,
|
||||
};
|
||||
});
|
||||
|
||||
await tx.deliverableStage.createMany({ data: stageData });
|
||||
|
||||
created.push({
|
||||
id: deliverable.id,
|
||||
name: deliverable.name,
|
||||
stageCount: templates.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fire async embedding updates (non-blocking)
|
||||
for (const c of created) {
|
||||
updateDeliverableEmbedding(c.id).catch(() => {});
|
||||
}
|
||||
updateProjectEmbedding(projectId).catch(() => {});
|
||||
|
||||
return { total: items.length, created, failed };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,114 @@ export interface ArtistSuggestion {
|
|||
overallScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available artists in the organization, ranked by capacity headroom.
|
||||
* "Available" = currently below max capacity. Optionally filter by skill match.
|
||||
*/
|
||||
export async function getAvailableArtists(
|
||||
organizationId: string,
|
||||
options: { skillIds?: string[]; departmentFilter?: string } = {}
|
||||
): Promise<ArtistSuggestion[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(options.departmentFilter
|
||||
? { department: options.departmentFilter }
|
||||
: {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
department: true,
|
||||
maxCapacity: true,
|
||||
skills: { include: { skill: true } },
|
||||
_count: {
|
||||
select: {
|
||||
assignments: {
|
||||
where: {
|
||||
deliverableStage: {
|
||||
status: {
|
||||
in: [
|
||||
"NOT_STARTED",
|
||||
"IN_PROGRESS",
|
||||
"IN_REVIEW",
|
||||
"CHANGES_REQUESTED",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const results: ArtistSuggestion[] = users
|
||||
.map((user) => {
|
||||
const activeAssignments = (user._count as any).assignments ?? 0;
|
||||
const utilizationPercent =
|
||||
user.maxCapacity > 0
|
||||
? Math.round((activeAssignments / user.maxCapacity) * 100)
|
||||
: 100;
|
||||
|
||||
// Skill filtering: if skillIds provided, compute match
|
||||
let skillMatchScore = 50; // neutral if no filter
|
||||
const matchedSkills: ArtistSuggestion["matchedSkills"] = [];
|
||||
const missingSkills: ArtistSuggestion["missingSkills"] = [];
|
||||
|
||||
if (options.skillIds && options.skillIds.length > 0) {
|
||||
const userSkillIds = new Set(user.skills.map((us) => us.skillId));
|
||||
let matched = 0;
|
||||
for (const sid of options.skillIds) {
|
||||
const userSkill = user.skills.find((us) => us.skillId === sid);
|
||||
if (userSkill) {
|
||||
matched++;
|
||||
matchedSkills.push({
|
||||
skillName: userSkill.skill.name,
|
||||
userLevel: userSkill.level,
|
||||
importance: 1,
|
||||
});
|
||||
} else {
|
||||
missingSkills.push({ skillName: sid, importance: 1 });
|
||||
}
|
||||
}
|
||||
skillMatchScore = Math.round(
|
||||
(matched / options.skillIds.length) * 100
|
||||
);
|
||||
}
|
||||
|
||||
const availabilityScore = Math.max(0, 100 - utilizationPercent);
|
||||
const overallScore = Math.round(
|
||||
skillMatchScore * 0.4 + availabilityScore * 0.6
|
||||
);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
userName: user.name || user.email,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
role: user.role,
|
||||
department: user.department,
|
||||
maxCapacity: user.maxCapacity,
|
||||
activeAssignments,
|
||||
utilizationPercent,
|
||||
skillMatchScore,
|
||||
matchedSkills,
|
||||
missingSkills,
|
||||
overallScore,
|
||||
};
|
||||
})
|
||||
// Only return users with headroom
|
||||
.filter((u) => u.activeAssignments < u.maxCapacity);
|
||||
|
||||
results.sort((a, b) => b.overallScore - a.overallScore);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested artists for a given stage template, ranked by skill match and current load.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,14 +3,274 @@ import { canTransition } from "@/lib/pipeline/stage-machine";
|
|||
import { canStageStart, getStageIdsToUnblock } from "@/lib/pipeline/dependency-engine";
|
||||
import type { StageStatus } from "@/generated/prisma/client";
|
||||
|
||||
// ─── Query Functions ────────────────────────────────────
|
||||
|
||||
export interface BlockedStageInfo {
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
stageSlug: string;
|
||||
deliverableId: string;
|
||||
deliverableName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectCode: string;
|
||||
blockedSince: Date;
|
||||
waitingOn: { stageName: string; stageSlug: string; status: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocked stages across an organization, with details on what they're waiting for.
|
||||
* Optionally filter by project.
|
||||
*/
|
||||
export async function getBlockedStages(
|
||||
organizationId: string,
|
||||
options: { projectId?: string } = {}
|
||||
): Promise<BlockedStageInfo[]> {
|
||||
const where: any = {
|
||||
status: "BLOCKED" as StageStatus,
|
||||
deliverable: {
|
||||
project: { organizationId },
|
||||
},
|
||||
};
|
||||
if (options.projectId) {
|
||||
where.deliverable.projectId = options.projectId;
|
||||
}
|
||||
|
||||
const blockedStages = await prisma.deliverableStage.findMany({
|
||||
where,
|
||||
include: {
|
||||
template: {
|
||||
include: { dependsOn: { include: { prerequisite: true } } },
|
||||
},
|
||||
deliverable: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, projectCode: true } },
|
||||
stages: {
|
||||
include: { template: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return blockedStages.map((stage) => {
|
||||
// Determine what prerequisites are still incomplete
|
||||
const waitingOn = stage.template.dependsOn
|
||||
.map((dep) => {
|
||||
const prereqStage = stage.deliverable.stages.find(
|
||||
(s) => s.templateId === dep.prerequisiteId
|
||||
);
|
||||
if (!prereqStage) return null;
|
||||
// Only include prerequisites that aren't yet complete
|
||||
const isComplete =
|
||||
prereqStage.status === "APPROVED" ||
|
||||
prereqStage.status === "DELIVERED" ||
|
||||
(prereqStage.template.isOptional && prereqStage.status === "SKIPPED");
|
||||
if (isComplete) return null;
|
||||
return {
|
||||
stageName: prereqStage.template.name,
|
||||
stageSlug: prereqStage.template.slug,
|
||||
status: prereqStage.status,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as BlockedStageInfo["waitingOn"];
|
||||
|
||||
return {
|
||||
stageId: stage.id,
|
||||
stageName: stage.template.name,
|
||||
stageSlug: stage.template.slug,
|
||||
deliverableId: stage.deliverableId,
|
||||
deliverableName: stage.deliverable.name,
|
||||
projectId: stage.deliverable.project.id,
|
||||
projectName: stage.deliverable.project.name,
|
||||
projectCode: stage.deliverable.project.projectCode,
|
||||
blockedSince: stage.createdAt,
|
||||
waitingOn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bulk Operations ────────────────────────────────────
|
||||
|
||||
export interface BulkStageUpdateItem {
|
||||
stageId: string;
|
||||
newStatus: StageStatus;
|
||||
subStatus?: string | null;
|
||||
}
|
||||
|
||||
export interface BulkStageUpdateResult {
|
||||
total: number;
|
||||
succeeded: { stageId: string; stageName: string; newStatus: string }[];
|
||||
failed: { stageId: string; stageName: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple stage statuses in a single transaction.
|
||||
* Each stage is validated independently; failures don't block other updates.
|
||||
* When dryRun is true, returns what would happen without executing.
|
||||
*/
|
||||
export async function bulkUpdateStageStatuses(
|
||||
items: BulkStageUpdateItem[],
|
||||
options: { dryRun?: boolean } = {}
|
||||
): Promise<BulkStageUpdateResult> {
|
||||
if (items.length === 0) {
|
||||
return { total: 0, succeeded: [], failed: [] };
|
||||
}
|
||||
|
||||
// Fetch all stages with their dependencies in one query
|
||||
const stageIds = items.map((i) => i.stageId);
|
||||
const stages = await prisma.deliverableStage.findMany({
|
||||
where: { id: { in: stageIds } },
|
||||
include: {
|
||||
template: { include: { dependsOn: true } },
|
||||
deliverable: {
|
||||
include: {
|
||||
stages: {
|
||||
include: {
|
||||
template: { include: { dependsOn: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const stageMap = new Map(stages.map((s) => [s.id, s]));
|
||||
const succeeded: BulkStageUpdateResult["succeeded"] = [];
|
||||
const failed: BulkStageUpdateResult["failed"] = [];
|
||||
|
||||
// Validate each item
|
||||
for (const item of items) {
|
||||
const stage = stageMap.get(item.stageId);
|
||||
if (!stage) {
|
||||
failed.push({
|
||||
stageId: item.stageId,
|
||||
stageName: "Unknown",
|
||||
reason: "Stage not found",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check transition validity
|
||||
const transitionCheck = canTransition(stage.status, item.newStatus);
|
||||
if (!transitionCheck.allowed) {
|
||||
failed.push({
|
||||
stageId: item.stageId,
|
||||
stageName: stage.template.name,
|
||||
reason: transitionCheck.reason || "Invalid transition",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check dependencies for IN_PROGRESS
|
||||
if (item.newStatus === "IN_PROGRESS") {
|
||||
const allStages = stage.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
isCriticalGate: s.template.isCriticalGate,
|
||||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
}));
|
||||
const currentStage = allStages.find((s) => s.id === item.stageId)!;
|
||||
const depCheck = canStageStart(currentStage, allStages);
|
||||
if (!depCheck.allowed) {
|
||||
failed.push({
|
||||
stageId: item.stageId,
|
||||
stageName: stage.template.name,
|
||||
reason: depCheck.reason || "Dependencies not met",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
succeeded.push({
|
||||
stageId: item.stageId,
|
||||
stageName: stage.template.name,
|
||||
newStatus: item.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// If dry run, return the preview without executing
|
||||
if (options.dryRun) {
|
||||
return { total: items.length, succeeded, failed };
|
||||
}
|
||||
|
||||
// Execute all valid updates in a transaction
|
||||
if (succeeded.length > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const item of succeeded) {
|
||||
const original = stageMap.get(item.stageId)!;
|
||||
const matchingInput = items.find((i) => i.stageId === item.stageId)!;
|
||||
|
||||
await tx.deliverableStage.update({
|
||||
where: { id: item.stageId },
|
||||
data: {
|
||||
status: matchingInput.newStatus,
|
||||
subStatus:
|
||||
matchingInput.subStatus !== undefined
|
||||
? matchingInput.subStatus
|
||||
: undefined,
|
||||
startDate:
|
||||
matchingInput.newStatus === "IN_PROGRESS" && !original.startDate
|
||||
? now
|
||||
: undefined,
|
||||
completedDate:
|
||||
matchingInput.newStatus === "APPROVED" ||
|
||||
matchingInput.newStatus === "DELIVERED"
|
||||
? now
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle critical gate unblocking
|
||||
if (
|
||||
(matchingInput.newStatus === "APPROVED" ||
|
||||
matchingInput.newStatus === "DELIVERED") &&
|
||||
original.template.isCriticalGate
|
||||
) {
|
||||
const allStages = original.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.id === item.stageId ? matchingInput.newStatus : s.status,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
isCriticalGate: s.template.isCriticalGate,
|
||||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
}));
|
||||
const approvedStage = allStages.find((s) => s.id === item.stageId)!;
|
||||
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
|
||||
if (idsToUnblock.length > 0) {
|
||||
await tx.deliverableStage.updateMany({
|
||||
where: { id: { in: idsToUnblock } },
|
||||
data: { status: "NOT_STARTED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { total: items.length, succeeded, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a stage's status with dependency enforcement.
|
||||
* When a critical gate is approved, automatically unblocks downstream stages.
|
||||
* When dryRun is true, validates and returns a preview without executing.
|
||||
*/
|
||||
export async function updateStageStatus(
|
||||
stageId: string,
|
||||
newStatus: StageStatus,
|
||||
subStatus?: string | null
|
||||
subStatus?: string | null,
|
||||
options: { dryRun?: boolean } = {}
|
||||
) {
|
||||
// Fetch the stage with its template, dependencies, and sibling stages
|
||||
const stage = await prisma.deliverableStage.findUnique({
|
||||
|
|
@ -63,6 +323,47 @@ export async function updateStageStatus(
|
|||
}
|
||||
}
|
||||
|
||||
// Build preview of what will happen
|
||||
const preview = {
|
||||
stageId,
|
||||
stageName: stage.template.name,
|
||||
currentStatus: stage.status,
|
||||
newStatus,
|
||||
willUnblockDownstream: false as boolean,
|
||||
downstreamStageNames: [] as string[],
|
||||
};
|
||||
|
||||
// Check if this will unblock downstream stages
|
||||
if (
|
||||
(newStatus === "APPROVED" || newStatus === "DELIVERED") &&
|
||||
stage.template.isCriticalGate
|
||||
) {
|
||||
const allStages = stage.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.id === stageId ? newStatus : s.status,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
isCriticalGate: s.template.isCriticalGate,
|
||||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
}));
|
||||
const approvedStage = allStages.find((s) => s.id === stageId)!;
|
||||
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
|
||||
if (idsToUnblock.length > 0) {
|
||||
preview.willUnblockDownstream = true;
|
||||
preview.downstreamStageNames = stage.deliverable.stages
|
||||
.filter((s) => idsToUnblock.includes(s.id))
|
||||
.map((s) => s.template.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Dry run: return preview without executing
|
||||
if (options.dryRun) {
|
||||
return { success: true, preview };
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue