Compare commits

...

13 Commits

Author SHA1 Message Date
Francisco Gaona
de65aa4025 WIP - using deep agent to create dog using workflow 2026-01-17 22:51:53 +01:00
Francisco Gaona
ded413b99b WIP - ai process builder codex attempt 2026-01-17 20:16:04 +01:00
Francisco Gaona
20fc90a3fb Add Contact standard object, related lists, meilisearch, pagination, search, AI assistant 2026-01-16 18:01:26 +01:00
Francisco Gaona
51c82d3d95 Fix nuxt config for HRM 2026-01-05 10:25:44 +01:00
Francisco Gaona
a4577ddcf3 Fix few warnings and console logs 2026-01-05 10:22:31 +01:00
Francisco Gaona
5f3fcef1ec Add twilio softphone with integrated AI assistant 2026-01-05 07:59:02 +01:00
Francisco Gaona
16907aadf8 Add record access strategy 2026-01-05 07:48:22 +01:00
Francisco Gaona
838a010fb2 Added page layouts 2025-12-23 09:44:05 +01:00
Francisco Gaona
be6e34914e Added better display of bread crums and side bar menus for apps and objects 2025-12-22 11:01:53 +01:00
Francisco Gaona
db9848cce7 Use routes closer to the route for objects 2025-12-22 10:24:02 +01:00
Francisco Gaona
cdc202454f Redirect to detail view of newly created record 2025-12-22 09:55:15 +01:00
Francisco Gaona
f4067c56b4 Added basic crud for objects 2025-12-22 09:36:39 +01:00
Francisco Gaona
0fe56c0e03 Added auth functionality, initial work with views and field types 2025-12-22 03:31:55 +01:00
328 changed files with 42786 additions and 1387 deletions

View File

@@ -2,8 +2,17 @@ NODE_ENV=development
PORT=3000 PORT=3000
DATABASE_URL="mysql://platform:platform@db:3306/platform" DATABASE_URL="mysql://platform:platform@db:3306/platform"
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
REDIS_URL="redis://redis:6379" REDIS_URL="redis://redis:6379"
# Meilisearch (optional)
MEILI_HOST="http://meilisearch:7700"
MEILI_API_KEY="dev-meili-master-key"
MEILI_INDEX_PREFIX="tenant_"
# JWT, multi-tenant hints, etc. # JWT, multi-tenant hints, etc.
JWT_SECRET="devsecret" JWT_SECRET="devsecret"
TENANCY_STRATEGY="single-db" TENANCY_STRATEGY="single-db"
CENTRAL_SUBDOMAINS="central,admin"

View File

@@ -2,4 +2,4 @@ NUXT_PORT=3001
NUXT_HOST=0.0.0.0 NUXT_HOST=0.0.0.0
# Point Nuxt to the API container (not localhost) # Point Nuxt to the API container (not localhost)
NUXT_PUBLIC_API_BASE_URL=http://jupiter.routebox.co:3000 NUXT_PUBLIC_API_BASE_URL=https://tenant1.routebox.co

View File

@@ -0,0 +1,324 @@
# AI Process Builder + Chat Orchestrator
A complete implementation of tenant-scoped AI process automation where admins design LangGraph-compiled workflows via React Flow UI, and end-users execute them through a Deep Agent chat orchestrator with deterministic, audited execution.
## Architecture Overview
### Backend Components
#### 1. **Deep Agent Orchestrator** ([deep-agent.orchestrator.ts](backend/src/ai-processes/deep-agent.orchestrator.ts))
- Uses LangChain/OpenAI to intelligently select processes
- Extracts structured inputs from natural language
- Generates friendly confirmation messages
- Three-step workflow: discover → select → extract → execute
#### 2. **Graph Compiler** ([ai-processes.compiler.ts](backend/src/ai-processes/ai-processes.compiler.ts))
- Validates ReactFlow JSON graphs (Start/End nodes, reachability, cycles)
- Compiles to LangGraph-compatible state machines
- Validates tool allowlist and JSON schemas (Ajv)
- Persists compiled artifact for versioned execution
#### 3. **Runtime Executor** ([ai-processes.runner.ts](backend/src/ai-processes/ai-processes.runner.ts))
- Executes compiled graphs deterministically
- Implements 4 node types: LLMDecisionNode, ToolNode, HumanInputNode, End
- Handles conditional edges via jsonlogic
- Emits real-time events for streaming updates
#### 4. **Tool Registry** ([tools/tool-registry.ts](backend/src/ai-processes/tools/tool-registry.ts))
- Tenant-scoped tool allowlist (database-backed via AiToolConfig)
- Demo tools wrapping ObjectService (findAccount, createAccount, etc.)
- Context injection (tenantId, userId, knex) for secure execution
#### 5. **Orchestrator Service** ([ai-processes.orchestrator.service.ts](backend/src/ai-processes/ai-processes.orchestrator.service.ts))
- Integrates Deep Agent for process selection
- Falls back to standard AI assistant when no processes configured
- Manages chat sessions and message history
- Streams execution events via SSE
### Frontend Components
#### 1. **AIChatBar** ([components/AIChatBar.vue](frontend/components/AIChatBar.vue))
- Updated to call `/ai-processes/chat/messages` endpoint
- SSE event stream consumer for real-time updates
- Displays process selection, node execution, tool calls
- Handles NEED_INPUT events for human-in-the-loop
#### 2. **Process Management UI** ([pages/ai-processes/](frontend/pages/ai-processes/))
- List view: displays all processes with versions
- Editor view: React Flow integration via iframe + postMessage
- Test runner for quick validation
#### 3. **React Flow Editor** ([ai-processes-editor/src/App.tsx](frontend/ai-processes-editor/src/App.tsx))
- Node palette: Start, LLMDecisionNode, ToolNode, HumanInputNode, End
- Visual graph designer with drag-drop
- Auto-saves to parent window via postMessage
- Loads existing graphs for editing
### Data Models (Objection.js)
```typescript
AiProcess
├── id, tenantId, name, description, latestVersion
└── relations: versions[], runs[]
AiProcessVersion
├── id, tenantId, processId, version
├── graphJson (ReactFlow definition)
└── compiledJson (LangGraph artifact)
AiProcessRun
├── id, tenantId, processId, version, status
├── inputJson, outputJson, errorJson, stateJson
└── currentNodeId (for resume)
AiChatSession
├── id, tenantId, userId
└── relations: messages[]
AiChatMessage
├── id, sessionId, role, content
└── timestamps
AiAuditEvent
├── id, tenantId, runId, eventType
└── payloadJson (full event data)
AiToolConfig
├── id, tenantId, toolName, enabled
└── configJson (tool-specific settings)
```
## Demo Process: Register New Pet
A complete workflow demonstrating conditional logic and tool orchestration:
1. **Extract Info** (LLMDecisionNode)
- Parses user message for pet + owner details
- Outputs structured JSON with validation
2. **Find/Create Account** (Conditional)
- Searches for existing account by name/email
- Creates new account if not found
- Merges results into state
3. **Find/Create Contact** (Conditional)
- Searches for existing contact under account
- Creates new contact if not found
4. **Create Pet** (ToolNode)
- Inserts pet record linked to contact
- Returns pet ID
### Seed the Demo Process
```bash
cd backend
npm run migrate:tenant -- <tenant-slug>
npm run seed:demo-process -- <tenant-slug>
```
### Test the Demo Process
1. Navigate to `/ai-processes` in your tenant subdomain
2. Open "Register New Pet" process
3. Click "Test Run" or use the chat bar:
```
User: "Register a dog named Max, breed Golden Retriever, age 3,
owned by John Smith, email john@example.com"
Agent: 🔄 Selected process: Register New Pet
I'll register Max (Golden Retriever, 3 years old) for John Smith.
⚙️ Executing step: Extract Info
✓ Extracted pet details
🔧 Using tool: findAccount
Account not found, creating new account
🔧 Using tool: createAccount
✓ Created account for John Smith
🔧 Using tool: findContact
Contact not found, creating new contact
🔧 Using tool: createContact
✓ Created contact: John Smith
🔧 Using tool: createPet
✓ Created pet: Max (ID: pet_1234567890)
✅ Process completed successfully!
```
## API Endpoints
### Process Management (Admin)
```typescript
GET /tenants/:tenantId/ai-processes
POST /tenants/:tenantId/ai-processes
GET /tenants/:tenantId/ai-processes/:id
POST /tenants/:tenantId/ai-processes/:id/versions
GET /tenants/:tenantId/ai-processes/:id/versions
POST /tenants/:tenantId/ai-processes/:id/runs
POST /tenants/:tenantId/ai-processes/runs/:runId/resume
```
### Chat Orchestrator (End User)
```typescript
POST /tenants/:tenantId/ai-processes/chat/messages
SSE /tenants/:tenantId/ai-processes/stream?sessionId=xxx
```
## Event Stream Types
```typescript
type StreamEvent =
| { type: 'agent_started' }
| { type: 'processes_listed', data: { count: number } }
| { type: 'process_selected', processId: string, version: number }
| { type: 'agent_message', data: { message: string } }
| { type: 'node_started', nodeId: string }
| { type: 'node_completed', nodeId: string }
| { type: 'tool_called', toolName: string, nodeId: string }
| { type: 'llm_decision', nodeId: string, data: any }
| { type: 'need_input', data: { prompt: string, schema: JSONSchema } }
| { type: 'final', data: { output: any } }
| { type: 'error', data: { error: string } }
```
## Security & Guardrails
### 1. **Tenancy Isolation**
- All queries filtered by `tenantId` (enforced in Objection models)
- Tool context includes tenant scope
- Database-per-tenant architecture (inherited from platform)
### 2. **Tool Allowlist**
- Two-level validation:
- Tenant-level: `AiToolConfig` table (enabled tools per tenant)
- Compile-time: validates toolName exists in registry
- Runtime check before tool execution
### 3. **Schema Validation**
- LLMDecisionNode output validated against JSON Schema (Ajv)
- HumanInputNode input validated before resume
- Graph structure validated at compile time
### 4. **Audit Trail**
- Every node execution logged to `ai_audit_events`
- Includes: tool calls, LLM decisions, state mutations, errors
- Queryable for compliance dashboards
### 5. **Versioning**
- Immutable process versions (create-only)
- Runs reference specific version number
- Graph definition + compiled artifact stored together
## Running the System
### 1. **Run Migrations**
```bash
cd backend
npm run migrate:tenant -- tenant1
```
### 2. **Seed Demo Data**
```bash
npm run seed:demo-process -- tenant1
```
### 3. **Start Backend**
```bash
npm run start:dev
```
### 4. **Build Editor (if needed)**
```bash
cd frontend/ai-processes-editor
npm install
npm run build
```
### 5. **Start Frontend**
```bash
cd frontend
npm run dev
```
### 6. **Access UI**
- Admin UI: `http://tenant1.localhost:3001/ai-processes`
- Chat UI: Available in bottom drawer on any page (⌘K to toggle)
## Extension Points
### Adding New Node Types
1. Define type in [ai-processes.types.ts](backend/src/ai-processes/ai-processes.types.ts)
2. Add schema validation in [ai-processes.schemas.ts](backend/src/ai-processes/ai-processes.schemas.ts)
3. Implement executor in [ai-processes.runner.ts](backend/src/ai-processes/ai-processes.runner.ts)
4. Add UI component in React Flow editor
### Adding New Tools
1. Implement handler in [tools/demo-tools.ts](backend/src/ai-processes/tools/demo-tools.ts)
2. Register in `demoTools` export
3. Add to tenant allowlist via UI or seed script
4. Document input/output schema
### Custom LLM Decision Logic
Override `llmDecision` callback in [ai-processes.service.ts](backend/src/ai-processes/ai-processes.service.ts):
```typescript
llmDecision: async (node, state) => {
const prompt = renderTemplate(node.data.promptTemplate, state);
const response = await callOpenAI(prompt, node.data.model);
return validateAgainstSchema(response, node.data.outputSchema);
}
```
## Troubleshooting
### Process not appearing in chat
- Check: `npm run seed:demo-process` completed successfully
- Verify: Process exists in database (`select * from ai_processes`)
- Check: Tools enabled (`select * from ai_tool_configs`)
### Graph validation errors
- Ensure exactly one Start node
- Ensure at least one End node
- Check all edges reference valid node IDs
- Verify tool names match registered tools
### SSE stream not working
- Check CORS settings for subdomain routing
- Verify `sessionId` returned from initial message
- Check browser console for connection errors
- Fallback: use polling endpoint (TODO: implement)
## Next Steps
1. **Enhanced Input Extraction**: Use Deep Agent to extract required fields per process
2. **Visual Schema Builder**: UI for JSON Schema creation (drag-drop fields)
3. **Conditional Edge Builder**: Visual jsonlogic editor
4. **Process Analytics**: Dashboard showing run success rates, avg duration
5. **Human-in-Loop UI**: Dynamic form renderer for HumanInputNode
6. **Process Marketplace**: Share processes across tenants (with permissions)
7. **Python Microservice**: Optional Python runtime for native LangGraph support
## License
MIT

83
DEBUG_INCOMING_CALL.md Normal file
View File

@@ -0,0 +1,83 @@
# Debugging Incoming Call Issue
## Current Problem
- Hear "Connecting to your call" message (TwiML is executing)
- No ring on mobile after "Connecting" message
- Click Accept button does nothing
- Call never connects
## Root Cause Hypothesis
The Twilio Device SDK is likely **NOT receiving the incoming call event** from Twilio's Signaling Server. This could be because:
1. **Identity Mismatch**: The Device's identity (from JWT token) doesn't match the `<Client>ID</Client>` in TwiML
2. **Device Not Registered**: Device registration isn't completing before the call arrives
3. **Twilio Signaling Issue**: Device isn't connected to Twilio Signaling Server
## How to Debug
### Step 1: Check Device Identity in Console
When you open the softphone dialog, **open Browser DevTools Console (F12)**
You should see logs like:
```
Token received, creating Device...
Token identity: e6d45fa3-a108-4085-81e5-a8e05e85e6fb
Token grants: {voice: {...}}
Registering Twilio Device...
✓ Twilio Device registered - ready to receive calls
Device identity: e6d45fa3-a108-4085-81e5-a8e05e85e6fb
Device state: ready
```
**Note the Device identity value** - e.g., "e6d45fa3-a108-4085-81e5-a8e05e85e6fb"
### Step 2: Check Backend Logs
When you make an inbound call, look for backend logs showing:
```
╔════════════════════════════════════════╗
║ === INBOUND CALL RECEIVED ===
╚════════════════════════════════════════╝
...
Client IDs to dial: e6d45fa3-a108-4085-81e5-a8e05e85e6fb
First Client ID format check: "e6d45fa3-a108-4085-81e5-a8e05e85e6fb" (length: 36)
```
### Step 3: Compare Identities
The Device identity from frontend console MUST MATCH the Client ID from backend logs.
**If they match**: The issue is with Twilio Signaling or Device SDK configuration
**If they don't match**: We found the bug - identity mismatch
### Step 4: Monitor Incoming Event
When you make the inbound call, keep watching the browser console for:
```
🔔 Twilio Device INCOMING event received: {...}
```
**If this appears**: The Device SDK IS receiving the call, so the Accept button issue is frontend
**If this doesn't appear**: The Device SDK is NOT receiving the call, so it's an identity/registration issue
## What Changed
- Frontend now relies on **Twilio Device SDK `incoming` event** (not Socket.IO) for showing incoming call
- Added comprehensive logging to Device initialization
- Added logging to Accept button handler
- Backend logs Device ID format for comparison
## Next Steps
1. Make an inbound call
2. Check browser console for the 5 logs above
3. Check backend logs for Client ID
4. Look for "🔔 Twilio Device INCOMING event" in browser console
5. Try clicking Accept and watch console for "📞 Accepting call" logs
6. Report back with:
- Device identity from console
- Client ID from backend logs
- Whether "🔔 Twilio Device INCOMING event" appears
- Whether any accept logs appear
## Important Files
- Backend: `/backend/src/voice/voice.controller.ts` (lines 205-210 show Client ID logging)
- Frontend: `/frontend/composables/useSoftphone.ts` (Device initialization and incoming handler)

173
SOFTPHONE_AI_ASSISTANT.md Normal file
View File

@@ -0,0 +1,173 @@
# Softphone AI Assistant - Complete Implementation
## 🎉 Features Implemented
### ✅ Real-time AI Call Assistant
- **OpenAI Realtime API Integration** - Listens to live calls and provides suggestions
- **Audio Streaming** - Twilio Media Streams fork audio to backend for AI processing
- **Real-time Transcription** - Speech-to-text during calls
- **Smart Suggestions** - AI analyzes conversation and advises the agent
## 🔧 Architecture
### Backend Flow
```
Inbound Call → TwiML (<Start><Stream> + <Dial>)
→ Media Stream WebSocket → OpenAI Realtime API
→ AI Processing → Socket.IO → Frontend
```
### Key Components
1. **TwiML Structure** (`voice.controller.ts:226-234`)
- `<Start><Stream>` - Forks audio for AI processing
- `<Dial><Client>` - Connects call to agent's softphone
2. **OpenAI Integration** (`voice.service.ts:431-519`)
- WebSocket connection to `wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01`
- Session config with custom instructions for agent assistance
- Handles transcripts and generates suggestions
3. **AI Message Handler** (`voice.service.ts:609-707`)
- Processes OpenAI events (transcripts, suggestions, audio)
- Routes suggestions to frontend via Socket.IO
- Saves transcripts to database
4. **Voice Gateway** (`voice.gateway.ts:272-289`)
- `notifyAiTranscript()` - Real-time transcript chunks
- `notifyAiSuggestion()` - AI suggestions to agent
### Frontend Components
1. **Softphone Dialog** (`SoftphoneDialog.vue:104-135`)
- AI Assistant section with badge showing suggestion count
- Color-coded suggestions (blue=response, green=action, purple=insight)
- Animated highlight for newest suggestion
2. **Softphone Composable** (`useSoftphone.ts:515-535`)
- Socket.IO event handlers for `ai:suggestion` and `ai:transcript`
- Maintains history of last 10 suggestions
- Maintains history of last 50 transcript items
## 📋 AI Prompt Configuration
The AI is instructed to:
- **Listen, not talk** - It advises the agent, not the caller
- **Provide concise suggestions** - 1-2 sentences max
- **Use formatted output**:
- `💡 Suggestion: [advice]`
- `⚠️ Alert: [important notice]`
- `📋 Action: [CRM action]`
## 🎨 UI Features
### Suggestion Types
- **Response** (Blue) - Suggested replies or approaches
- **Action** (Green) - Recommended CRM actions
- **Insight** (Purple) - Important alerts or observations
### Visual Feedback
- Badge showing number of suggestions
- Newest suggestion pulses for attention
- Auto-scrolling suggestion list
- Timestamp on each suggestion
## 🔍 How to Monitor
### 1. Backend Logs
```bash
# Watch for AI events
docker logs -f neo-backend-1 | grep -E "AI|OpenAI|transcript|suggestion"
```
Key log markers:
- `📝 Transcript chunk:` - Real-time speech detection
- `✅ Final transcript:` - Complete transcript saved
- `💡 AI Suggestion:` - AI-generated advice
### 2. Database
```sql
-- View call transcripts
SELECT call_sid, ai_transcript, created_at
FROM calls
ORDER BY created_at DESC
LIMIT 5;
```
### 3. Frontend Console
- Open browser DevTools Console
- Watch for: "AI suggestion:", "AI transcript:"
## 🚀 Testing
1. **Make a test call** to your Twilio number
2. **Accept the call** in the softphone dialog
3. **Talk during the call** - Say something like "I need to schedule a follow-up"
4. **Watch the UI** - AI suggestions appear in real-time
5. **Check logs** - See transcription and suggestion generation
## 📊 Current Status
**Working**:
- Inbound calls ring softphone
- Media stream forks audio to backend
- OpenAI processes audio (1300+ packets/call)
- AI generates suggestions
- Suggestions appear in frontend
- Transcripts saved to database
## 🔧 Configuration
### Required Environment Variables
```env
# OpenAI API Key (set in tenant integrations config)
OPENAI_API_KEY=sk-...
# Optional overrides
OPENAI_MODEL=gpt-4o-realtime-preview-2024-10-01
OPENAI_VOICE=alloy
```
### Tenant Configuration
Set in Settings > Integrations:
- OpenAI API Key
- Model (optional)
- Voice (optional)
## 🎯 Next Steps (Optional Enhancements)
1. **CRM Tool Execution** - Implement actual tool calls (search contacts, create tasks)
2. **Audio Response** - Send OpenAI audio back to caller (two-way AI interaction)
3. **Sentiment Analysis** - Track call sentiment in real-time
4. **Call Summary** - Generate post-call summary automatically
5. **Custom Prompts** - Allow agents to customize AI instructions per call type
## 🐛 Troubleshooting
### No suggestions appearing?
1. Check OpenAI API key is configured
2. Verify WebSocket connection logs show "OpenAI Realtime connected"
3. Check frontend Socket.IO connection is established
4. Verify user ID matches between backend and frontend
### Transcripts not saving?
1. Check tenant database connection
2. Verify `calls` table has `ai_transcript` column
3. Check logs for "Failed to update transcript" errors
### OpenAI connection fails?
1. Verify API key is valid
2. Check model name is correct
3. Review WebSocket close codes in logs
## 📝 Files Modified
**Backend:**
- `/backend/src/voice/voice.service.ts` - OpenAI integration & AI message handling
- `/backend/src/voice/voice.controller.ts` - TwiML generation with stream fork
- `/backend/src/voice/voice.gateway.ts` - Socket.IO event emission
- `/backend/src/main.ts` - Media stream WebSocket handler
**Frontend:**
- `/frontend/components/SoftphoneDialog.vue` - AI suggestions UI
- `/frontend/composables/useSoftphone.ts` - Socket.IO event handlers

23
backend/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Central Database (Prisma - stores tenant metadata)
CENTRAL_DATABASE_URL="mysql://user:password@platform-db:3306/central_platform"
# Database Root Credentials (for tenant provisioning)
DB_HOST="platform-db"
DB_PORT="3306"
DB_ROOT_USER="root"
DB_ROOT_PASSWORD="root"
# Encryption Key for Tenant Database Passwords (32-byte hex string)
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY="your-32-byte-hex-encryption-key-here"
# JWT Configuration
JWT_SECRET="your-jwt-secret"
JWT_EXPIRES_IN="7d"
# Application
NODE_ENV="development"
PORT="3000"
# Central Admin Subdomains (comma-separated list of subdomains that access the central database)
CENTRAL_SUBDOMAINS="central,admin"

View File

@@ -0,0 +1,91 @@
╔══════════════════════════════════════════════════════════════════════╗
║ TENANT MIGRATION - QUICK REFERENCE ║
╚══════════════════════════════════════════════════════════════════════╝
📍 LOCATION: /root/neo/backend
┌─────────────────────────────────────────────────────────────────────┐
│ COMMON COMMANDS │
└─────────────────────────────────────────────────────────────────────┘
Create Migration:
$ npm run migrate:make add_my_feature
Check Status:
$ npm run migrate:status
Test on One Tenant:
$ npm run migrate:tenant acme-corp
Apply to All Tenants:
$ npm run migrate:all-tenants
┌─────────────────────────────────────────────────────────────────────┐
│ ALL AVAILABLE COMMANDS │
└─────────────────────────────────────────────────────────────────────┘
npm run migrate:make <name> Create new migration file
npm run migrate:status Check status across all tenants
npm run migrate:tenant <slug> Migrate specific tenant
npm run migrate:all-tenants Migrate all active tenants
npm run migrate:latest Migrate default DB (rarely used)
npm run migrate:rollback Rollback default DB (rarely used)
┌─────────────────────────────────────────────────────────────────────┐
│ TYPICAL WORKFLOW │
└─────────────────────────────────────────────────────────────────────┘
1. Create: npm run migrate:make add_priority_field
2. Edit: vim migrations/tenant/20250127_*.js
3. Test: npm run migrate:tenant test-company
4. Status: npm run migrate:status
5. Deploy: npm run migrate:all-tenants
┌─────────────────────────────────────────────────────────────────────┐
│ ENVIRONMENT REQUIRED │
└─────────────────────────────────────────────────────────────────────┘
export DB_ENCRYPTION_KEY="your-32-character-secret-key!!"
┌─────────────────────────────────────────────────────────────────────┐
│ FILE LOCATIONS │
└─────────────────────────────────────────────────────────────────────┘
Scripts: backend/scripts/migrate-*.ts
Migrations: backend/migrations/tenant/
Config: backend/knexfile.js
Docs: TENANT_MIGRATION_GUIDE.md
┌─────────────────────────────────────────────────────────────────────┐
│ DOCUMENTATION │
└─────────────────────────────────────────────────────────────────────┘
Quick Guide: cat TENANT_MIGRATION_GUIDE.md
Script Docs: cat backend/scripts/README.md
Complete: cat TENANT_MIGRATION_IMPLEMENTATION_COMPLETE.md
┌─────────────────────────────────────────────────────────────────────┐
│ TROUBLESHOOTING │
└─────────────────────────────────────────────────────────────────────┘
Missing Prisma Client:
$ npx prisma generate --schema=prisma/schema-central.prisma
Check Scripts Available:
$ npm run | grep migrate
Connection Error:
- Check DB_ENCRYPTION_KEY matches encryption key
- Verify central database is accessible
- Ensure tenant databases are online
╔══════════════════════════════════════════════════════════════════════╗
║ For detailed help: cat TENANT_MIGRATION_GUIDE.md ║
╚══════════════════════════════════════════════════════════════════════╝

View File

@@ -0,0 +1,115 @@
-- Insert demo AI process directly
SET @process_id = '2d883482-4df0-44d7-b6cf-8541b482afe4';
SET @version_id = '437b1e72-405e-4862-a8bc-f368e554b482';
SET @user_id = 'system';
-- Insert process
INSERT INTO ai_processes (id, name, created_by)
VALUES (@process_id, 'Register New Pet', @user_id);
-- Insert process version with compiled graph
INSERT INTO ai_process_versions (id, process_id, version, graph_json, compiled_json, created_by)
VALUES (
@version_id,
@process_id,
1,
'{}',
JSON_OBJECT(
'id', 'register_new_pet',
'name', 'Register New Pet',
'description', 'Complete pet registration workflow',
'allowCycles', false,
'startNodeId', 'start',
'endNodeIds', JSON_ARRAY('end'),
'maxIterations', 50,
'nodes', JSON_ARRAY(
JSON_OBJECT('id', 'start', 'type', 'Start', 'data', JSON_OBJECT('label', 'Start')),
JSON_OBJECT('id', 'extract_info', 'type', 'LLMDecisionNode', 'data', JSON_OBJECT(
'label', 'Extract Info',
'promptTemplate', 'Extract: petName, species, ownerFirstName, ownerLastName, ownerEmail, accountName from: {{state.message}}',
'inputKeys', JSON_ARRAY('message'),
'outputSchema', JSON_OBJECT(
'type', 'object',
'properties', JSON_OBJECT(
'petName', JSON_OBJECT('type', 'string'),
'species', JSON_OBJECT('type', 'string'),
'ownerFirstName', JSON_OBJECT('type', 'string'),
'ownerLastName', JSON_OBJECT('type', 'string'),
'ownerEmail', JSON_OBJECT('type', 'string'),
'accountName', JSON_OBJECT('type', 'string')
),
'required', JSON_ARRAY('petName', 'species', 'ownerFirstName', 'ownerLastName')
)
)),
JSON_OBJECT('id', 'find_account', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Find Account',
'toolName', 'findAccount',
'argsTemplate', JSON_OBJECT('name', '{{state.accountName}}', 'email', '{{state.ownerEmail}}'),
'outputMapping', JSON_OBJECT('found', 'accountFound', 'accountId', 'accountId')
)),
JSON_OBJECT('id', 'create_account', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Account',
'toolName', 'createAccount',
'argsTemplate', JSON_OBJECT('name', '{{state.accountName}}', 'email', '{{state.ownerEmail}}'),
'outputMapping', JSON_OBJECT('accountId', 'accountId')
)),
JSON_OBJECT('id', 'find_contact', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Find Contact',
'toolName', 'findContact',
'argsTemplate', JSON_OBJECT(
'firstName', '{{state.ownerFirstName}}',
'lastName', '{{state.ownerLastName}}',
'email', '{{state.ownerEmail}}',
'accountId', '{{state.accountId}}'
),
'outputMapping', JSON_OBJECT('found', 'contactFound', 'contactId', 'contactId')
)),
JSON_OBJECT('id', 'create_contact', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Contact',
'toolName', 'createContact',
'argsTemplate', JSON_OBJECT(
'firstName', '{{state.ownerFirstName}}',
'lastName', '{{state.ownerLastName}}',
'email', '{{state.ownerEmail}}',
'accountId', '{{state.accountId}}'
),
'outputMapping', JSON_OBJECT('contactId', 'contactId')
)),
JSON_OBJECT('id', 'create_pet', 'type', 'ToolNode', 'data', JSON_OBJECT(
'label', 'Create Pet',
'toolName', 'createPet',
'argsTemplate', JSON_OBJECT(
'name', '{{state.petName}}',
'species', '{{state.species}}',
'ownerId', '{{state.contactId}}'
),
'outputMapping', JSON_OBJECT('petId', 'petId')
)),
JSON_OBJECT('id', 'end', 'type', 'End', 'data', JSON_OBJECT('label', 'End'))
),
'edges', JSON_ARRAY(
JSON_OBJECT('id', 'e1', 'source', 'start', 'target', 'extract_info'),
JSON_OBJECT('id', 'e2', 'source', 'extract_info', 'target', 'find_account'),
JSON_OBJECT('id', 'e3', 'source', 'find_account', 'target', 'find_contact', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'accountFound'), true))),
JSON_OBJECT('id', 'e4', 'source', 'find_account', 'target', 'create_account', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'accountFound'), false))),
JSON_OBJECT('id', 'e5', 'source', 'create_account', 'target', 'find_contact'),
JSON_OBJECT('id', 'e6', 'source', 'find_contact', 'target', 'create_pet', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'contactFound'), true))),
JSON_OBJECT('id', 'e7', 'source', 'find_contact', 'target', 'create_contact', 'condition', JSON_OBJECT('==', JSON_ARRAY(JSON_OBJECT('var', 'contactFound'), false))),
JSON_OBJECT('id', 'e8', 'source', 'create_contact', 'target', 'create_pet'),
JSON_OBJECT('id', 'e9', 'source', 'create_pet', 'target', 'end')
)
),
@user_id
);
-- Insert tool allowlist
INSERT INTO ai_tool_configs (id, tool_name, enabled)
VALUES
(UUID(), 'findAccount', true),
(UUID(), 'createAccount', true),
(UUID(), 'findContact', true),
(UUID(), 'createContact', true),
(UUID(), 'createPet', true)
ON DUPLICATE KEY UPDATE enabled = true;
SELECT 'Demo process inserted successfully!' as result;

19
backend/knexfile.js Normal file
View File

@@ -0,0 +1,19 @@
module.exports = {
development: {
client: 'mysql2',
connection: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'root',
database: process.env.DB_NAME || 'tenant_template',
},
migrations: {
directory: './migrations/tenant',
tableName: 'knex_migrations',
},
seeds: {
directory: './seeds/tenant',
},
},
};

View File

@@ -0,0 +1,78 @@
exports.up = function (knex) {
return knex.schema
.createTable('users', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('email', 255).notNullable();
table.string('password', 255).notNullable();
table.string('firstName', 255);
table.string('lastName', 255);
table.boolean('isActive').defaultTo(true);
table.timestamps(true, true);
table.unique(['email']);
table.index(['email']);
})
.createTable('roles', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('name', 255).notNullable();
table.string('guardName', 255).defaultTo('api');
table.text('description');
table.timestamps(true, true);
table.unique(['name', 'guardName']);
})
.createTable('permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('name', 255).notNullable();
table.string('guardName', 255).defaultTo('api');
table.text('description');
table.timestamps(true, true);
table.unique(['name', 'guardName']);
})
.createTable('role_permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('roleId').notNullable();
table.uuid('permissionId').notNullable();
table.timestamps(true, true);
table
.foreign('roleId')
.references('id')
.inTable('roles')
.onDelete('CASCADE');
table
.foreign('permissionId')
.references('id')
.inTable('permissions')
.onDelete('CASCADE');
table.unique(['roleId', 'permissionId']);
})
.createTable('user_roles', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('userId').notNullable();
table.uuid('roleId').notNullable();
table.timestamps(true, true);
table
.foreign('userId')
.references('id')
.inTable('users')
.onDelete('CASCADE');
table
.foreign('roleId')
.references('id')
.inTable('roles')
.onDelete('CASCADE');
table.unique(['userId', 'roleId']);
});
};
exports.down = function (knex) {
return knex.schema
.dropTableIfExists('user_roles')
.dropTableIfExists('role_permissions')
.dropTableIfExists('permissions')
.dropTableIfExists('roles')
.dropTableIfExists('users');
};

View File

@@ -0,0 +1,48 @@
exports.up = function (knex) {
return knex.schema
.createTable('object_definitions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('apiName', 255).notNullable().unique();
table.string('label', 255).notNullable();
table.string('pluralLabel', 255);
table.text('description');
table.boolean('isSystem').defaultTo(false);
table.boolean('isCustom').defaultTo(true);
table.timestamps(true, true);
table.index(['apiName']);
})
.createTable('field_definitions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('objectDefinitionId').notNullable();
table.string('apiName', 255).notNullable();
table.string('label', 255).notNullable();
table.string('type', 50).notNullable(); // String, Number, Date, Boolean, Reference, etc.
table.integer('length');
table.integer('precision');
table.integer('scale');
table.string('referenceObject', 255);
table.text('defaultValue');
table.text('description');
table.boolean('isRequired').defaultTo(false);
table.boolean('isUnique').defaultTo(false);
table.boolean('isSystem').defaultTo(false);
table.boolean('isCustom').defaultTo(true);
table.integer('displayOrder').defaultTo(0);
table.timestamps(true, true);
table
.foreign('objectDefinitionId')
.references('id')
.inTable('object_definitions')
.onDelete('CASCADE');
table.unique(['objectDefinitionId', 'apiName']);
table.index(['objectDefinitionId']);
});
};
exports.down = function (knex) {
return knex.schema
.dropTableIfExists('field_definitions')
.dropTableIfExists('object_definitions');
};

View File

@@ -0,0 +1,35 @@
exports.up = function (knex) {
return knex.schema
.createTable('apps', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('slug', 255).notNullable().unique();
table.string('label', 255).notNullable();
table.text('description');
table.integer('display_order').defaultTo(0);
table.timestamps(true, true);
table.index(['slug']);
})
.createTable('app_pages', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('app_id').notNullable();
table.string('slug', 255).notNullable();
table.string('label', 255).notNullable();
table.string('type', 50).notNullable(); // List, Detail, Custom
table.string('object_api_name', 255);
table.integer('display_order').defaultTo(0);
table.timestamps(true, true);
table
.foreign('app_id')
.references('id')
.inTable('apps')
.onDelete('CASCADE');
table.unique(['app_id', 'slug']);
table.index(['app_id']);
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('app_pages').dropTableIfExists('apps');
};

View File

@@ -0,0 +1,29 @@
exports.up = function (knex) {
return knex.schema.createTable('custom_migrations', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('tenantId').notNullable();
table.string('name', 255).notNullable();
table.text('description');
table.enum('type', [
'create_table',
'add_column',
'alter_column',
'add_index',
'drop_table',
'custom',
]).notNullable();
table.text('sql').notNullable();
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
table.timestamp('executedAt').nullable();
table.text('error').nullable();
table.timestamps(true, true);
table.index(['tenantId']);
table.index(['status']);
table.index(['created_at']);
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('custom_migrations');
};

View File

@@ -0,0 +1,111 @@
exports.up = async function (knex) {
// Create standard Account object
await knex.schema.createTable('accounts', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('name', 255).notNullable();
table.string('website', 255);
table.string('phone', 50);
table.string('industry', 100);
table.uuid('ownerId');
table.timestamps(true, true);
table
.foreign('ownerId')
.references('id')
.inTable('users')
.onDelete('SET NULL');
table.index(['name']);
table.index(['ownerId']);
});
// Insert Account object definition
const [objectId] = await knex('object_definitions').insert({
id: knex.raw('(UUID())'),
apiName: 'Account',
label: 'Account',
pluralLabel: 'Accounts',
description: 'Standard Account object',
isSystem: true,
isCustom: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
// Insert Account field definitions
const objectDefId =
objectId ||
(await knex('object_definitions').where('apiName', 'Account').first()).id;
await knex('field_definitions').insert([
{
id: knex.raw('(UUID())'),
objectDefinitionId: objectDefId,
apiName: 'name',
label: 'Account Name',
type: 'String',
length: 255,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 1,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: objectDefId,
apiName: 'website',
label: 'Website',
type: 'String',
length: 255,
isSystem: true,
isCustom: false,
displayOrder: 2,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: objectDefId,
apiName: 'phone',
label: 'Phone',
type: 'String',
length: 50,
isSystem: true,
isCustom: false,
displayOrder: 3,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: objectDefId,
apiName: 'industry',
label: 'Industry',
type: 'String',
length: 100,
isSystem: true,
isCustom: false,
displayOrder: 4,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: objectDefId,
apiName: 'ownerId',
label: 'Owner',
type: 'Reference',
referenceObject: 'User',
isSystem: true,
isCustom: false,
displayOrder: 5,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
]);
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('accounts');
};

View File

@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.table('field_definitions', (table) => {
table.jsonb('ui_metadata').nullable().comment('JSON metadata for UI rendering including display options, validation rules, and field-specific configurations');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.table('field_definitions', (table) => {
table.dropColumn('ui_metadata');
});
};

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.string('nameField', 255).comment('API name of the field to use as record display name');
});
};
exports.down = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.dropColumn('nameField');
});
};

View File

@@ -0,0 +1,22 @@
exports.up = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.uuid('app_id').nullable()
.comment('Optional: App that this object belongs to');
table
.foreign('app_id')
.references('id')
.inTable('apps')
.onDelete('SET NULL');
table.index(['app_id']);
});
};
exports.down = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.dropForeign('app_id');
table.dropIndex('app_id');
table.dropColumn('app_id');
});
};

View File

@@ -0,0 +1,29 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.createTable('page_layouts', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('name').notNullable();
table.uuid('object_id').notNullable();
table.boolean('is_default').defaultTo(false);
table.json('layout_config').notNullable();
table.text('description');
table.timestamps(true, true);
// Foreign key to object_definitions
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
// Index for faster lookups
table.index(['object_id', 'is_default']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.dropTable('page_layouts');
};

View File

@@ -0,0 +1,103 @@
exports.up = function (knex) {
return knex.schema
// Add orgWideDefault to object_definitions
.alterTable('object_definitions', (table) => {
table
.enum('orgWideDefault', ['private', 'public_read', 'public_read_write'])
.defaultTo('private')
.notNullable();
})
// Create role_object_permissions table
.createTable('role_object_permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('roleId').notNullable();
table.uuid('objectDefinitionId').notNullable();
table.boolean('canCreate').defaultTo(false);
table.boolean('canRead').defaultTo(false);
table.boolean('canEdit').defaultTo(false);
table.boolean('canDelete').defaultTo(false);
table.boolean('canViewAll').defaultTo(false);
table.boolean('canModifyAll').defaultTo(false);
table.timestamps(true, true);
table
.foreign('roleId')
.references('id')
.inTable('roles')
.onDelete('CASCADE');
table
.foreign('objectDefinitionId')
.references('id')
.inTable('object_definitions')
.onDelete('CASCADE');
table.unique(['roleId', 'objectDefinitionId']);
table.index(['roleId']);
table.index(['objectDefinitionId']);
})
// Create role_field_permissions table
.createTable('role_field_permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('roleId').notNullable();
table.uuid('fieldDefinitionId').notNullable();
table.boolean('canRead').defaultTo(true);
table.boolean('canEdit').defaultTo(true);
table.timestamps(true, true);
table
.foreign('roleId')
.references('id')
.inTable('roles')
.onDelete('CASCADE');
table
.foreign('fieldDefinitionId')
.references('id')
.inTable('field_definitions')
.onDelete('CASCADE');
table.unique(['roleId', 'fieldDefinitionId']);
table.index(['roleId']);
table.index(['fieldDefinitionId']);
})
// Create record_shares table for sharing specific records
.createTable('record_shares', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('objectDefinitionId').notNullable();
table.uuid('recordId').notNullable();
table.uuid('granteeUserId').notNullable();
table.uuid('grantedByUserId').notNullable();
table.json('accessLevel').notNullable(); // { canRead, canEdit, canDelete }
table.timestamp('expiresAt').nullable();
table.timestamp('revokedAt').nullable();
table.timestamp('createdAt').defaultTo(knex.fn.now());
table.timestamp('updatedAt').defaultTo(knex.fn.now());
table
.foreign('objectDefinitionId')
.references('id')
.inTable('object_definitions')
.onDelete('CASCADE');
table
.foreign('granteeUserId')
.references('id')
.inTable('users')
.onDelete('CASCADE');
table
.foreign('grantedByUserId')
.references('id')
.inTable('users')
.onDelete('CASCADE');
table.index(['objectDefinitionId', 'recordId']);
table.index(['granteeUserId']);
table.index(['expiresAt']);
table.index(['revokedAt']);
});
};
exports.down = function (knex) {
return knex.schema
.dropTableIfExists('record_shares')
.dropTableIfExists('role_field_permissions')
.dropTableIfExists('role_object_permissions')
.alterTable('object_definitions', (table) => {
table.dropColumn('orgWideDefault');
});
};

View File

@@ -0,0 +1,55 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
// Create calls table for tracking voice calls
await knex.schema.createTable('calls', (table) => {
table.string('id', 36).primary();
table.string('call_sid', 100).unique().notNullable().comment('Twilio call SID');
table.enum('direction', ['inbound', 'outbound']).notNullable();
table.string('from_number', 20).notNullable();
table.string('to_number', 20).notNullable();
table.enum('status', [
'queued',
'ringing',
'in-progress',
'completed',
'busy',
'failed',
'no-answer',
'canceled'
]).notNullable().defaultTo('queued');
table.integer('duration_seconds').unsigned().nullable();
table.string('recording_url', 500).nullable();
table.text('ai_transcript').nullable().comment('Full transcript from OpenAI');
table.text('ai_summary').nullable().comment('AI-generated summary');
table.json('ai_insights').nullable().comment('Structured insights from AI');
table.string('user_id', 36).notNullable().comment('User who handled the call');
table.timestamp('started_at').nullable();
table.timestamp('ended_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Indexes
table.index('call_sid');
table.index('user_id');
table.index('status');
table.index('direction');
table.index(['created_at', 'user_id']);
// Foreign key to users table
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE');
});
console.log('✅ Created calls table');
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('calls');
console.log('✅ Dropped calls table');
};

View File

@@ -0,0 +1,207 @@
exports.up = async function (knex) {
await knex.schema.createTable('contacts', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('firstName', 100).notNullable();
table.string('lastName', 100).notNullable();
table.uuid('accountId').notNullable();
table.timestamps(true, true);
table
.foreign('accountId')
.references('id')
.inTable('accounts')
.onDelete('CASCADE');
table.index(['accountId']);
table.index(['lastName', 'firstName']);
});
await knex.schema.createTable('contact_details', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('relatedObjectType', 100).notNullable();
table.uuid('relatedObjectId').notNullable();
table.string('detailType', 50).notNullable();
table.string('label', 100);
table.text('value').notNullable();
table.boolean('isPrimary').defaultTo(false);
table.timestamps(true, true);
table.index(['relatedObjectType', 'relatedObjectId']);
table.index(['detailType']);
});
const [contactObjectId] = await knex('object_definitions').insert({
id: knex.raw('(UUID())'),
apiName: 'Contact',
label: 'Contact',
pluralLabel: 'Contacts',
description: 'Standard Contact object',
isSystem: true,
isCustom: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const contactObjectDefId =
contactObjectId ||
(await knex('object_definitions').where('apiName', 'Contact').first()).id;
await knex('field_definitions').insert([
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactObjectDefId,
apiName: 'firstName',
label: 'First Name',
type: 'String',
length: 100,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 1,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactObjectDefId,
apiName: 'lastName',
label: 'Last Name',
type: 'String',
length: 100,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 2,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactObjectDefId,
apiName: 'accountId',
label: 'Account',
type: 'Reference',
referenceObject: 'Account',
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 3,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
]);
const [contactDetailObjectId] = await knex('object_definitions').insert({
id: knex.raw('(UUID())'),
apiName: 'ContactDetail',
label: 'Contact Detail',
pluralLabel: 'Contact Details',
description: 'Polymorphic contact detail object',
isSystem: true,
isCustom: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const contactDetailObjectDefId =
contactDetailObjectId ||
(await knex('object_definitions').where('apiName', 'ContactDetail').first())
.id;
const contactDetailRelationObjects = ['Account', 'Contact']
await knex('field_definitions').insert([
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectType',
label: 'Related Object Type',
type: 'PICKLIST',
length: 100,
isRequired: true,
isSystem: false,
isCustom: false,
displayOrder: 1,
ui_metadata: JSON.stringify({
options: contactDetailRelationObjects.map((value) => ({ label: value, value })),
}),
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectId',
label: 'Related Object ID',
type: 'LOOKUP',
length: 36,
isRequired: true,
isSystem: false,
isCustom: false,
displayOrder: 2,
ui_metadata: JSON.stringify({
relationObjects: contactDetailRelationObjects,
relationTypeField: 'relatedObjectType',
relationDisplayField: 'name',
}),
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'detailType',
label: 'Detail Type',
type: 'String',
length: 50,
isRequired: true,
isSystem: false,
isCustom: false,
displayOrder: 3,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'label',
label: 'Label',
type: 'String',
length: 100,
isSystem: false,
isCustom: false,
displayOrder: 4,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'value',
label: 'Value',
type: 'Text',
isRequired: true,
isSystem: false,
isCustom: false,
displayOrder: 5,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'isPrimary',
label: 'Primary',
type: 'Boolean',
isSystem: false,
isCustom: false,
displayOrder: 6,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
]);
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('contact_details');
await knex.schema.dropTableIfExists('contacts');
};

View File

@@ -0,0 +1,101 @@
exports.up = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
const relationObjects = ['Account', 'Contact'];
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectType',
})
.update({
type: 'PICKLIST',
length: 100,
isSystem: false,
ui_metadata: JSON.stringify({
options: relationObjects.map((value) => ({ label: value, value })),
}),
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectId',
})
.update({
type: 'LOOKUP',
length: 36,
isSystem: false,
ui_metadata: JSON.stringify({
relationObjects,
relationTypeField: 'relatedObjectType',
relationDisplayField: 'name',
}),
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.whereIn('apiName', [
'detailType',
'label',
'value',
'isPrimary',
])
.andWhere({ objectDefinitionId: contactDetailObject.id })
.update({
isSystem: false,
updated_at: knex.fn.now(),
});
};
exports.down = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectType',
})
.update({
type: 'String',
length: 100,
isSystem: true,
ui_metadata: null,
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectId',
})
.update({
type: 'String',
length: 36,
isSystem: true,
ui_metadata: null,
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.whereIn('apiName', [
'detailType',
'label',
'value',
'isPrimary',
])
.andWhere({ objectDefinitionId: contactDetailObject.id })
.update({
isSystem: true,
updated_at: knex.fn.now(),
});
};

View File

@@ -0,0 +1,45 @@
exports.up = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
await knex('field_definitions')
.where({ objectDefinitionId: contactDetailObject.id })
.whereIn('apiName', [
'relatedObjectType',
'relatedObjectId',
'detailType',
'label',
'value',
'isPrimary',
])
.update({
isSystem: false,
updated_at: knex.fn.now(),
});
};
exports.down = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
await knex('field_definitions')
.where({ objectDefinitionId: contactDetailObject.id })
.whereIn('apiName', [
'relatedObjectType',
'relatedObjectId',
'detailType',
'label',
'value',
'isPrimary',
])
.update({
isSystem: true,
updated_at: knex.fn.now(),
});
};

View File

@@ -0,0 +1,62 @@
exports.up = async function (knex) {
// Add ownerId column to contacts
await knex.schema.alterTable('contacts', (table) => {
table.uuid('ownerId');
table
.foreign('ownerId')
.references('id')
.inTable('users')
.onDelete('SET NULL');
table.index(['ownerId']);
});
// Add ownerId field definition metadata for Contact object
const contactObject = await knex('object_definitions')
.where('apiName', 'Contact')
.first();
if (contactObject) {
const existingField = await knex('field_definitions')
.where({
objectDefinitionId: contactObject.id,
apiName: 'ownerId',
})
.first();
if (!existingField) {
await knex('field_definitions').insert({
id: knex.raw('(UUID())'),
objectDefinitionId: contactObject.id,
apiName: 'ownerId',
label: 'Owner',
type: 'Reference',
referenceObject: 'User',
isSystem: true,
isCustom: false,
displayOrder: 4,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
};
exports.down = async function (knex) {
const contactObject = await knex('object_definitions')
.where('apiName', 'Contact')
.first();
if (contactObject) {
await knex('field_definitions')
.where({
objectDefinitionId: contactObject.id,
apiName: 'ownerId',
})
.delete();
}
await knex.schema.alterTable('contacts', (table) => {
table.dropForeign(['ownerId']);
table.dropColumn('ownerId');
});
};

View File

@@ -0,0 +1,72 @@
exports.up = async function (knex) {
await knex.schema.createTable('ai_processes', (table) => {
table.uuid('id').primary();
table.string('name').notNullable();
table.text('description');
table.integer('latest_version').notNullable().defaultTo(1);
table.string('created_by').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
await knex.schema.createTable('ai_process_versions', (table) => {
table.uuid('id').primary();
table.uuid('process_id').notNullable();
table.integer('version').notNullable();
table.json('graph_json').notNullable();
table.json('compiled_json').notNullable();
table.string('created_by').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.unique(['process_id', 'version']);
table.index(['process_id']);
});
await knex.schema.createTable('ai_process_runs', (table) => {
table.uuid('id').primary();
table.uuid('process_id').notNullable();
table.integer('version').notNullable();
table.string('status').notNullable();
table.json('input_json').notNullable();
table.json('output_json');
table.json('error_json');
table.json('state_json');
table.string('current_node_id');
table.timestamp('started_at').defaultTo(knex.fn.now());
table.timestamp('ended_at');
table.index(['process_id']);
});
await knex.schema.createTable('ai_chat_sessions', (table) => {
table.uuid('id').primary();
table.string('user_id').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['user_id']);
});
await knex.schema.createTable('ai_chat_messages', (table) => {
table.uuid('id').primary();
table.uuid('session_id').notNullable();
table.string('role').notNullable();
table.text('content').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['session_id']);
});
await knex.schema.createTable('ai_audit_events', (table) => {
table.uuid('id').primary();
table.uuid('run_id').notNullable();
table.string('event_type').notNullable();
table.json('payload_json').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['run_id']);
});
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('ai_audit_events');
await knex.schema.dropTableIfExists('ai_chat_messages');
await knex.schema.dropTableIfExists('ai_chat_sessions');
await knex.schema.dropTableIfExists('ai_process_runs');
await knex.schema.dropTableIfExists('ai_process_versions');
await knex.schema.dropTableIfExists('ai_processes');
};

View File

@@ -0,0 +1,14 @@
exports.up = async function (knex) {
await knex.schema.createTable('ai_tool_configs', (table) => {
table.uuid('id').primary();
table.string('tool_name').notNullable().unique();
table.boolean('enabled').notNullable().defaultTo(true);
table.json('config_json');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('ai_tool_configs');
};

1662
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,36 +17,63 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json",
"migrate:make": "knex migrate:make --knexfile=knexfile.js",
"migrate:latest": "knex migrate:latest --knexfile=knexfile.js",
"migrate:rollback": "knex migrate:rollback --knexfile=knexfile.js",
"migrate:status": "ts-node -r tsconfig-paths/register scripts/check-migration-status.ts",
"migrate:tenant": "ts-node -r tsconfig-paths/register scripts/migrate-tenant.ts",
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts",
"seed:demo-process": "ts-node -r tsconfig-paths/register scripts/seed-demo-process.ts"
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.12",
"@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0", "@nestjs/core": "^10.3.0",
"@nestjs/platform-fastify": "^10.3.0",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/config": "^3.1.1", "@nestjs/platform-fastify": "^10.3.0",
"@nestjs/bullmq": "^10.1.0", "@nestjs/platform-socket.io": "^10.4.20",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20",
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",
"passport": "^0.7.0", "@types/json-logic-js": "^2.0.8",
"passport-jwt": "^4.0.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.1.0", "bullmq": "^5.1.0",
"ioredis": "^5.3.2",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.3.2",
"json-logic-js": "^2.0.5",
"knex": "^3.1.0",
"langchain": "^1.2.7",
"mysql2": "^3.15.3",
"objection": "^3.1.5",
"openai": "^6.15.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"twilio": "^5.11.1",
"ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.0", "@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0", "@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0", "@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0", "@types/passport-jwt": "^4.0.0",
"@types/bcrypt": "^5.0.2",
"@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0", "@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",

View File

@@ -0,0 +1,116 @@
/*
Warnings:
- You are about to drop the column `isActive` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the `accounts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `app_pages` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `apps` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `field_definitions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `object_definitions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `permissions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `role_permissions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `roles` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `user_roles` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `users` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `dbHost` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbName` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbPassword` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbUsername` to the `tenants` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_ownerId_fkey`;
-- DropForeignKey
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_appId_fkey`;
-- DropForeignKey
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_objectId_fkey`;
-- DropForeignKey
ALTER TABLE `apps` DROP FOREIGN KEY `apps_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `field_definitions` DROP FOREIGN KEY `field_definitions_objectId_fkey`;
-- DropForeignKey
ALTER TABLE `object_definitions` DROP FOREIGN KEY `object_definitions_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `permissions` DROP FOREIGN KEY `permissions_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_permissionId_fkey`;
-- DropForeignKey
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_roleId_fkey`;
-- DropForeignKey
ALTER TABLE `roles` DROP FOREIGN KEY `roles_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_roleId_fkey`;
-- DropForeignKey
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_userId_fkey`;
-- DropForeignKey
ALTER TABLE `users` DROP FOREIGN KEY `users_tenantId_fkey`;
-- AlterTable
ALTER TABLE `tenants` DROP COLUMN `isActive`,
ADD COLUMN `dbHost` VARCHAR(191) NOT NULL,
ADD COLUMN `dbName` VARCHAR(191) NOT NULL,
ADD COLUMN `dbPassword` VARCHAR(191) NOT NULL,
ADD COLUMN `dbPort` INTEGER NOT NULL DEFAULT 3306,
ADD COLUMN `dbUsername` VARCHAR(191) NOT NULL,
ADD COLUMN `status` VARCHAR(191) NOT NULL DEFAULT 'active';
-- DropTable
DROP TABLE `accounts`;
-- DropTable
DROP TABLE `app_pages`;
-- DropTable
DROP TABLE `apps`;
-- DropTable
DROP TABLE `field_definitions`;
-- DropTable
DROP TABLE `object_definitions`;
-- DropTable
DROP TABLE `permissions`;
-- DropTable
DROP TABLE `role_permissions`;
-- DropTable
DROP TABLE `roles`;
-- DropTable
DROP TABLE `user_roles`;
-- DropTable
DROP TABLE `users`;
-- CreateTable
CREATE TABLE `domains` (
`id` VARCHAR(191) NOT NULL,
`domain` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`isPrimary` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `domains_domain_key`(`domain`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `domains` ADD CONSTRAINT `domains_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,238 @@
/*
Warnings:
- You are about to drop the column `dbHost` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the column `dbName` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the column `dbPassword` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the column `dbPort` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the column `dbUsername` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the column `status` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the `domains` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE `domains` DROP FOREIGN KEY `domains_tenantId_fkey`;
-- AlterTable
ALTER TABLE `tenants` DROP COLUMN `dbHost`,
DROP COLUMN `dbName`,
DROP COLUMN `dbPassword`,
DROP COLUMN `dbPort`,
DROP COLUMN `dbUsername`,
DROP COLUMN `status`,
ADD COLUMN `isActive` BOOLEAN NOT NULL DEFAULT true;
-- DropTable
DROP TABLE `domains`;
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NULL,
`lastName` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `users_tenantId_idx`(`tenantId`),
UNIQUE INDEX `users_tenantId_email_key`(`tenantId`, `email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `roles` (
`id` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`guardName` VARCHAR(191) NOT NULL DEFAULT 'api',
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `roles_tenantId_idx`(`tenantId`),
UNIQUE INDEX `roles_tenantId_name_guardName_key`(`tenantId`, `name`, `guardName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `permissions` (
`id` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`guardName` VARCHAR(191) NOT NULL DEFAULT 'api',
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `permissions_tenantId_idx`(`tenantId`),
UNIQUE INDEX `permissions_tenantId_name_guardName_key`(`tenantId`, `name`, `guardName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `user_roles` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `user_roles_userId_idx`(`userId`),
INDEX `user_roles_roleId_idx`(`roleId`),
UNIQUE INDEX `user_roles_userId_roleId_key`(`userId`, `roleId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `role_permissions` (
`id` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(191) NOT NULL,
`permissionId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `role_permissions_roleId_idx`(`roleId`),
INDEX `role_permissions_permissionId_idx`(`permissionId`),
UNIQUE INDEX `role_permissions_roleId_permissionId_key`(`roleId`, `permissionId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `object_definitions` (
`id` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`apiName` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`pluralLabel` VARCHAR(191) NULL,
`description` TEXT NULL,
`isSystem` BOOLEAN NOT NULL DEFAULT false,
`tableName` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `object_definitions_tenantId_idx`(`tenantId`),
UNIQUE INDEX `object_definitions_tenantId_apiName_key`(`tenantId`, `apiName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `field_definitions` (
`id` VARCHAR(191) NOT NULL,
`objectId` VARCHAR(191) NOT NULL,
`apiName` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`isRequired` BOOLEAN NOT NULL DEFAULT false,
`isUnique` BOOLEAN NOT NULL DEFAULT false,
`isReadonly` BOOLEAN NOT NULL DEFAULT false,
`isLookup` BOOLEAN NOT NULL DEFAULT false,
`referenceTo` VARCHAR(191) NULL,
`defaultValue` VARCHAR(191) NULL,
`options` JSON NULL,
`validationRules` JSON NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `field_definitions_objectId_idx`(`objectId`),
UNIQUE INDEX `field_definitions_objectId_apiName_key`(`objectId`, `apiName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `accounts` (
`id` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'active',
`ownerId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `accounts_tenantId_idx`(`tenantId`),
INDEX `accounts_ownerId_idx`(`ownerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `apps` (
`id` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`slug` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`icon` VARCHAR(191) NULL,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `apps_tenantId_idx`(`tenantId`),
UNIQUE INDEX `apps_tenantId_slug_key`(`tenantId`, `slug`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `app_pages` (
`id` VARCHAR(191) NOT NULL,
`appId` VARCHAR(191) NOT NULL,
`slug` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`objectApiName` VARCHAR(191) NULL,
`objectId` VARCHAR(191) NULL,
`config` JSON NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `app_pages_appId_idx`(`appId`),
INDEX `app_pages_objectId_idx`(`objectId`),
UNIQUE INDEX `app_pages_appId_slug_key`(`appId`, `slug`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `users` ADD CONSTRAINT `users_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `roles` ADD CONSTRAINT `roles_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `permissions` ADD CONSTRAINT `permissions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `permissions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `object_definitions` ADD CONSTRAINT `object_definitions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `field_definitions` ADD CONSTRAINT `field_definitions_objectId_fkey` FOREIGN KEY (`objectId`) REFERENCES `object_definitions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `apps` ADD CONSTRAINT `apps_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `app_pages` ADD CONSTRAINT `app_pages_appId_fkey` FOREIGN KEY (`appId`) REFERENCES `apps`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `app_pages` ADD CONSTRAINT `app_pages_objectId_fkey` FOREIGN KEY (`objectId`) REFERENCES `object_definitions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,116 @@
/*
Warnings:
- You are about to drop the column `isActive` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the `accounts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `app_pages` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `apps` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `field_definitions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `object_definitions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `permissions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `role_permissions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `roles` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `user_roles` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `users` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `dbHost` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbName` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbPassword` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbUsername` to the `tenants` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_ownerId_fkey`;
-- DropForeignKey
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_appId_fkey`;
-- DropForeignKey
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_objectId_fkey`;
-- DropForeignKey
ALTER TABLE `apps` DROP FOREIGN KEY `apps_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `field_definitions` DROP FOREIGN KEY `field_definitions_objectId_fkey`;
-- DropForeignKey
ALTER TABLE `object_definitions` DROP FOREIGN KEY `object_definitions_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `permissions` DROP FOREIGN KEY `permissions_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_permissionId_fkey`;
-- DropForeignKey
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_roleId_fkey`;
-- DropForeignKey
ALTER TABLE `roles` DROP FOREIGN KEY `roles_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_roleId_fkey`;
-- DropForeignKey
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_userId_fkey`;
-- DropForeignKey
ALTER TABLE `users` DROP FOREIGN KEY `users_tenantId_fkey`;
-- AlterTable
ALTER TABLE `tenants` DROP COLUMN `isActive`,
ADD COLUMN `dbHost` VARCHAR(191) NOT NULL,
ADD COLUMN `dbName` VARCHAR(191) NOT NULL,
ADD COLUMN `dbPassword` VARCHAR(191) NOT NULL,
ADD COLUMN `dbPort` INTEGER NOT NULL DEFAULT 3306,
ADD COLUMN `dbUsername` VARCHAR(191) NOT NULL,
ADD COLUMN `status` VARCHAR(191) NOT NULL DEFAULT 'active';
-- DropTable
DROP TABLE `accounts`;
-- DropTable
DROP TABLE `app_pages`;
-- DropTable
DROP TABLE `apps`;
-- DropTable
DROP TABLE `field_definitions`;
-- DropTable
DROP TABLE `object_definitions`;
-- DropTable
DROP TABLE `permissions`;
-- DropTable
DROP TABLE `role_permissions`;
-- DropTable
DROP TABLE `roles`;
-- DropTable
DROP TABLE `user_roles`;
-- DropTable
DROP TABLE `users`;
-- CreateTable
CREATE TABLE `domains` (
`id` VARCHAR(191) NOT NULL,
`domain` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`isPrimary` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `domains_domain_key`(`domain`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `domains` ADD CONSTRAINT `domains_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NULL,
`lastName` VARCHAR(191) NULL,
`role` VARCHAR(191) NOT NULL DEFAULT 'admin',
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `tenants` ADD COLUMN `integrationsConfig` JSON NULL;

View File

@@ -0,0 +1,56 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/central"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("CENTRAL_DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
firstName String?
lastName String?
role String @default("admin") // admin, superadmin
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique // Used for identification
dbHost String // Database host
dbPort Int @default(3306)
dbName String // Database name
dbUsername String // Database username
dbPassword String // Encrypted database password
integrationsConfig Json? // Encrypted JSON config for external services (Twilio, OpenAI, etc.)
status String @default("active") // active, suspended, deleted
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
domains Domain[]
@@map("tenants")
}
model Domain {
id String @id @default(cuid())
domain String @unique // e.g., "acme" for acme.yourapp.com
tenantId String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("domains")
}

View File

@@ -1,39 +1,22 @@
// This is your Prisma schema file, // Tenant-specific database schema
// learn more about it in the docs: https://pris.ly/d/prisma-schema // This schema is applied to each tenant's database
// NOTE: Each tenant has its own database, so there is NO tenantId column in these tables
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../node_modules/.prisma/tenant"
binaryTargets = ["native", "debian-openssl-3.0.x"]
} }
datasource db { datasource db {
provider = "mysql" provider = "mysql"
url = env("DATABASE_URL") url = env("TENANT_DATABASE_URL")
}
// Multi-tenancy
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
objectDefinitions ObjectDefinition[]
accounts Account[]
apps App[]
roles Role[]
permissions Permission[]
@@map("tenants")
} }
// User & Auth // User & Auth
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String email String @unique
email String
password String password String
firstName String? firstName String?
lastName String? lastName String?
@@ -41,48 +24,39 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
userRoles UserRole[] userRoles UserRole[]
accounts Account[] accounts Account[]
@@unique([tenantId, email])
@@index([tenantId])
@@map("users") @@map("users")
} }
// RBAC - Spatie-like // RBAC - Spatie-like
model Role { model Role {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String
name String name String
guardName String @default("api") guardName String @default("api")
description String? description String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
userRoles UserRole[] userRoles UserRole[]
rolePermissions RolePermission[] rolePermissions RolePermission[]
@@unique([tenantId, name, guardName]) @@unique([name, guardName])
@@index([tenantId])
@@map("roles") @@map("roles")
} }
model Permission { model Permission {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String
name String name String
guardName String @default("api") guardName String @default("api")
description String? description String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
rolePermissions RolePermission[] rolePermissions RolePermission[]
@@unique([tenantId, name, guardName]) @@unique([name, guardName])
@@index([tenantId])
@@map("permissions") @@map("permissions")
} }
@@ -119,75 +93,193 @@ model RolePermission {
// Object Definition (Metadata) // Object Definition (Metadata)
model ObjectDefinition { model ObjectDefinition {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String apiName String @unique
apiName String
label String label String
pluralLabel String? pluralLabel String?
description String? @db.Text description String? @db.Text
isSystem Boolean @default(false) isSystem Boolean @default(false)
tableName String? isCustom Boolean @default(true)
isActive Boolean @default(true) createdAt DateTime @default(now()) @map("created_at")
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
fields FieldDefinition[] fields FieldDefinition[]
pages AppPage[] pages AppPage[]
@@unique([tenantId, apiName])
@@index([tenantId])
@@map("object_definitions") @@map("object_definitions")
} }
model FieldDefinition { model FieldDefinition {
id String @id @default(uuid()) id String @id @default(uuid())
objectId String objectDefinitionId String
apiName String apiName String
label String label String
type String // text, number, boolean, date, datetime, lookup, picklist, etc. type String // String, Number, Date, Boolean, Reference, etc.
description String? @db.Text length Int?
isRequired Boolean @default(false) precision Int?
isUnique Boolean @default(false) scale Int?
isReadonly Boolean @default(false) referenceObject String?
isLookup Boolean @default(false) defaultValue String? @db.Text
referenceTo String? // objectApiName for lookup fields description String? @db.Text
defaultValue String? isRequired Boolean @default(false)
options Json? // for picklist fields isUnique Boolean @default(false)
validationRules Json? // custom validation rules isSystem Boolean @default(false)
isActive Boolean @default(true) isCustom Boolean @default(true)
createdAt DateTime @default(now()) displayOrder Int @default(0)
updatedAt DateTime @updatedAt uiMetadata Json? @map("ui_metadata")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
object ObjectDefinition @relation(fields: [objectId], references: [id], onDelete: Cascade) object ObjectDefinition @relation(fields: [objectDefinitionId], references: [id], onDelete: Cascade)
@@unique([objectId, apiName]) @@unique([objectDefinitionId, apiName])
@@index([objectId]) @@index([objectDefinitionId])
@@map("field_definitions") @@map("field_definitions")
} }
// Example static object: Account // Example static object: Account
model Account { model Account {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String
name String name String
status String @default("active") status String @default("active")
ownerId String ownerId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id])
owner User @relation(fields: [ownerId], references: [id]) contacts Contact[]
@@index([tenantId])
@@index([ownerId]) @@index([ownerId])
@@map("accounts") @@map("accounts")
} }
model Contact {
id String @id @default(uuid())
firstName String
lastName String
accountId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@index([accountId])
@@map("contacts")
}
model ContactDetail {
id String @id @default(uuid())
relatedObjectType String
relatedObjectId String
detailType String
label String?
value String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([relatedObjectType, relatedObjectId])
@@map("contact_details")
}
// AI Process Builder + Chat Orchestrator
model AiProcess {
id String @id @default(uuid())
tenantId String @map("tenant_id")
name String
description String? @db.Text
latestVersion Int @default(1) @map("latest_version")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
versions AiProcessVersion[]
runs AiProcessRun[]
@@index([tenantId])
@@map("ai_processes")
}
model AiProcessVersion {
id String @id @default(uuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
version Int
graphJson Json @map("graph_json")
compiledJson Json @map("compiled_json")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade)
@@unique([processId, version])
@@index([tenantId])
@@map("ai_process_versions")
}
model AiProcessRun {
id String @id @default(uuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
version Int
status String
inputJson Json @map("input_json")
outputJson Json? @map("output_json")
errorJson Json? @map("error_json")
stateJson Json? @map("state_json")
currentNodeId String? @map("current_node_id")
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
process AiProcess @relation(fields: [processId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([processId])
@@map("ai_process_runs")
}
model AiChatSession {
id String @id @default(uuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
messages AiChatMessage[]
@@index([tenantId])
@@index([userId])
@@map("ai_chat_sessions")
}
model AiChatMessage {
id String @id @default(uuid())
sessionId String @map("session_id")
role String
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
session AiChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@map("ai_chat_messages")
}
model AiAuditEvent {
id String @id @default(uuid())
tenantId String @map("tenant_id")
runId String @map("run_id")
eventType String @map("event_type")
payloadJson Json @map("payload_json")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@index([runId])
@@map("ai_audit_events")
}
// Application Builder // Application Builder
model App { model App {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String slug String @unique
slug String
label String label String
description String? @db.Text description String? @db.Text
icon String? icon String?
@@ -195,11 +287,8 @@ model App {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
pages AppPage[] pages AppPage[]
@@unique([tenantId, slug])
@@index([tenantId])
@@map("apps") @@map("apps")
} }

239
backend/scripts/README.md Normal file
View File

@@ -0,0 +1,239 @@
# Tenant Migration & Admin Scripts
This directory contains scripts for managing database migrations across all tenants and creating admin users in the multi-tenant platform.
## Admin User Management
### Create Central Admin User
```bash
npm run create-central-admin
```
Creates an administrator user in the **central database**. Central admins can:
- Manage tenants (create, update, delete)
- Access platform-wide administration features
- View all tenant information
- Manage tenant provisioning
**Interactive Mode:**
```bash
npm run create-central-admin
# You will be prompted for:
# - Email
# - Password
# - First Name (optional)
# - Last Name (optional)
# - Role (admin or superadmin)
```
**Non-Interactive Mode (using environment variables):**
```bash
EMAIL=admin@example.com PASSWORD=securepass123 FIRST_NAME=John LAST_NAME=Doe ROLE=superadmin npm run create-central-admin
```
**Logging In as Central Admin:**
1. Access the application using a central subdomain (e.g., `central.yourdomain.com` or `admin.yourdomain.com`)
2. Enter your central admin credentials
3. You'll be authenticated against the central database (not a tenant database)
**Note:** The system automatically detects if you're logging in from a central subdomain based on the `CENTRAL_SUBDOMAINS` environment variable (defaults to `central,admin`). No special UI or configuration is needed on the frontend.
### Create Tenant User
For creating users within a specific tenant database, use:
```bash
npm run create-tenant-user <tenant-slug>
# (Note: This script may need to be created or already exists)
```
## Migration Scripts
### 1. Create a New Migration
```bash
npm run migrate:make <migration_name>
```
Creates a new migration file in `migrations/tenant/` directory.
**Example:**
```bash
npm run migrate:make add_status_field_to_contacts
```
### 2. Migrate a Single Tenant
```bash
npm run migrate:tenant <tenant-slug-or-id>
```
Runs all pending migrations for a specific tenant. You can identify the tenant by its slug or ID.
**Example:**
```bash
npm run migrate:tenant acme-corp
npm run migrate:tenant cm5a1b2c3d4e5f6g7h8i9j0k
```
### 3. Migrate All Tenants
```bash
npm run migrate:all-tenants
```
Runs all pending migrations for **all active tenants** in the system. This is useful when:
- You've created a new migration that needs to be applied to all tenants
- You're updating the schema across the entire platform
- You need to ensure all tenants are up to date
**Output:**
- Shows progress for each tenant
- Lists which migrations were applied
- Provides a summary at the end
- Exits with error code if any tenant fails
### 4. Rollback Migration (Manual)
```bash
npm run migrate:rollback
```
⚠️ **Warning:** This runs a rollback on the **default database** configured in `knexfile.js`. For tenant-specific rollbacks, you'll need to manually configure the connection.
## Migration Flow
### During New Tenant Provisioning
When a new tenant is created via the API, migrations are automatically run as part of the provisioning process:
1. Tenant database is created
2. `TenantProvisioningService.runTenantMigrations()` is called
3. All migrations in `migrations/tenant/` are executed
### For Existing Tenants
When you add a new migration file and need to apply it to existing tenants:
1. Create the migration:
```bash
npm run migrate:make add_new_feature
```
2. Edit the generated migration file in `migrations/tenant/`
3. Test on a single tenant first:
```bash
npm run migrate:tenant test-tenant
```
4. If successful, apply to all tenants:
```bash
npm run migrate:all-tenants
```
## Migration Directory Structure
```
backend/
├── migrations/
│ └── tenant/ # Tenant-specific migrations
│ ├── 20250126000001_create_users_and_rbac.js
│ ├── 20250126000002_create_object_definitions.js
│ └── ...
├── scripts/
│ ├── migrate-tenant.ts # Single tenant migration
│ └── migrate-all-tenants.ts # All tenants migration
└── knexfile.js # Knex configuration
```
## Security Notes
### Database Password Encryption
Tenant database passwords are encrypted in the central database using AES-256-CBC encryption. The migration scripts automatically:
1. Fetch tenant connection details from the central database
2. Decrypt the database password using the `DB_ENCRYPTION_KEY` environment variable
3. Connect to the tenant database
4. Run migrations
5. Close the connection
**Required Environment Variable:**
```bash
DB_ENCRYPTION_KEY=your-32-character-secret-key!!
```
This key must match the key used by `TenantService` for encryption.
## Troubleshooting
### Migration Fails for One Tenant
If `migrate:all-tenants` fails for a specific tenant:
1. Check the error message in the output
2. Investigate the tenant's database directly
3. Fix the issue (manual SQL, data cleanup, etc.)
4. Re-run migrations for that tenant: `npm run migrate:tenant <slug>`
5. Once fixed, run `migrate:all-tenants` again to ensure others are updated
### Migration Already Exists
Knex tracks which migrations have been run in the `knex_migrations` table in each tenant database. If a migration was already applied, it will be skipped automatically.
### Connection Issues
If you see connection errors:
1. Verify the central database is accessible
2. Check that tenant database credentials are correct
3. Ensure `DB_ENCRYPTION_KEY` matches the one used for encryption
4. Verify the tenant's database server is running and accessible
## Example Migration File
```javascript
// migrations/tenant/20250126000006_add_custom_fields.js
exports.up = async function(knex) {
await knex.schema.table('field_definitions', (table) => {
table.boolean('is_custom').defaultTo(false);
table.string('custom_type', 50).nullable();
});
};
exports.down = async function(knex) {
await knex.schema.table('field_definitions', (table) => {
table.dropColumn('is_custom');
table.dropColumn('custom_type');
});
};
```
## Best Practices
1. **Always test on a single tenant first** before running migrations on all tenants
2. **Include rollback logic** in your `down()` function
3. **Use transactions** for complex multi-step migrations
4. **Backup production databases** before running migrations
5. **Monitor the output** when running `migrate:all-tenants` to catch any failures
6. **Version control** your migration files
7. **Document breaking changes** in migration comments
8. **Consider data migrations** separately from schema migrations when dealing with large datasets
## CI/CD Integration
In your deployment pipeline, you can automatically migrate all tenants:
```bash
# After deploying new code
npm run migrate:all-tenants
```
Or integrate it into your Docker deployment:
```dockerfile
# In your Dockerfile or docker-compose.yml
CMD npm run migrate:all-tenants && npm run start:prod
```

View File

@@ -0,0 +1,181 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
import knex, { Knex } from 'knex';
import { createDecipheriv } from 'crypto';
// Encryption configuration
const ALGORITHM = 'aes-256-cbc';
/**
* Decrypt a tenant's database password
*/
function decryptPassword(encryptedPassword: string): string {
try {
// Check if password is already plaintext (for legacy/development)
if (!encryptedPassword.includes(':')) {
return encryptedPassword;
}
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
if (parts.length !== 2) {
throw new Error('Invalid encrypted password format');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Error decrypting password:', error);
throw error;
}
}
/**
* Create a Knex connection for a specific tenant
*/
function createTenantKnexConnection(tenant: any): Knex {
const decryptedPassword = decryptPassword(tenant.dbPassword);
return knex({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations/tenant',
},
});
}
/**
* Get migration status for a specific tenant
*/
async function getTenantMigrationStatus(tenant: any): Promise<{
completed: string[];
pending: string[];
}> {
const tenantKnex = createTenantKnexConnection(tenant);
try {
const [completed, pending] = await tenantKnex.migrate.list();
return {
completed: completed[1] || [],
pending: pending || [],
};
} catch (error) {
throw error;
} finally {
await tenantKnex.destroy();
}
}
/**
* Check migration status across all tenants
*/
async function checkMigrationStatus() {
console.log('🔍 Checking migration status for all tenants...\n');
const centralPrisma = new CentralPrismaClient();
try {
// Fetch all active tenants
const tenants = await centralPrisma.tenant.findMany({
where: {
status: 'ACTIVE',
},
orderBy: {
name: 'asc',
},
});
if (tenants.length === 0) {
console.log('⚠️ No active tenants found.');
return;
}
console.log(`📋 Found ${tenants.length} active tenant(s)\n`);
console.log('='.repeat(80));
let allUpToDate = true;
const tenantsWithPending: { name: string; pending: string[] }[] = [];
// Check each tenant
for (const tenant of tenants) {
try {
const status = await getTenantMigrationStatus(tenant);
console.log(`\n📦 ${tenant.name} (${tenant.slug})`);
console.log(` Database: ${tenant.dbName}`);
console.log(` Completed: ${status.completed.length} migration(s)`);
if (status.pending.length > 0) {
allUpToDate = false;
console.log(` ⚠️ Pending: ${status.pending.length} migration(s)`);
status.pending.forEach((migration) => {
console.log(` - ${migration}`);
});
tenantsWithPending.push({
name: tenant.name,
pending: status.pending,
});
} else {
console.log(` ✅ Up to date`);
}
// Show last 3 completed migrations
if (status.completed.length > 0) {
const recent = status.completed.slice(-3);
console.log(` Recent migrations:`);
recent.forEach((migration) => {
console.log(` - ${migration}`);
});
}
} catch (error) {
console.log(`\n❌ ${tenant.name}: Failed to check status`);
console.log(` Error: ${error.message}`);
allUpToDate = false;
}
}
// Print summary
console.log('\n' + '='.repeat(80));
console.log('📊 Summary');
console.log('='.repeat(80));
if (allUpToDate) {
console.log('✅ All tenants are up to date!');
} else {
console.log(`⚠️ ${tenantsWithPending.length} tenant(s) have pending migrations:\n`);
tenantsWithPending.forEach(({ name, pending }) => {
console.log(` ${name}: ${pending.length} pending`);
});
console.log('\n💡 Run: npm run migrate:all-tenants');
}
} catch (error) {
console.error('❌ Fatal error:', error);
process.exit(1);
} finally {
await centralPrisma.$disconnect();
}
}
// Run the status check
checkMigrationStatus()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,50 @@
import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central';
import * as bcrypt from 'bcrypt';
// Central database client
const centralPrisma = new CentralPrismaClient();
async function createAdminUser() {
const email = 'admin@example.com';
const password = 'admin123';
const firstName = 'Admin';
const lastName = 'User';
try {
// Check if admin user already exists
const existingUser = await centralPrisma.user.findUnique({
where: { email },
});
if (existingUser) {
console.log(`User ${email} already exists`);
return;
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create admin user in central database
const user = await centralPrisma.user.create({
data: {
email,
password: hashedPassword,
firstName,
lastName,
role: 'superadmin',
isActive: true,
},
});
console.log('\nAdmin user created successfully!');
console.log('Email:', email);
console.log('Password:', password);
console.log('User ID:', user.id);
} catch (error) {
console.error('Error creating admin user:', error);
} finally {
await centralPrisma.$disconnect();
}
}
createAdminUser();

View File

@@ -0,0 +1,138 @@
import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central';
import * as bcrypt from 'bcrypt';
import { Knex, knex } from 'knex';
// Central database client
const centralPrisma = new CentralPrismaClient();
async function createTenantUser() {
const tenantSlug = 'tenant1';
const email = 'user@example.com';
const password = 'user123';
const firstName = 'Test';
const lastName = 'User';
try {
// Get tenant database connection info
const tenant = await centralPrisma.tenant.findFirst({
where: { slug: tenantSlug },
});
if (!tenant) {
console.log(`Tenant ${tenantSlug} not found. Creating tenant...`);
// Create tenant in central database
const newTenant = await centralPrisma.tenant.create({
data: {
name: 'Default Tenant',
slug: tenantSlug,
dbHost: 'db',
dbPort: 3306,
dbName: 'platform',
dbUsername: 'platform',
dbPassword: 'platform',
status: 'active',
},
});
console.log('Tenant created:', newTenant.slug);
} else {
console.log('Tenant found:', tenant.slug);
}
const tenantInfo = tenant || {
dbHost: 'db',
dbPort: 3306,
dbName: 'platform',
dbUsername: 'platform',
dbPassword: 'platform',
};
// Connect to tenant database (using root for now since tenant password is encrypted)
const tenantDb: Knex = knex({
client: 'mysql2',
connection: {
host: tenantInfo.dbHost,
port: tenantInfo.dbPort,
database: tenantInfo.dbName,
user: 'root',
password: 'asjdnfqTash37faggT',
},
});
// Check if user already exists
const existingUser = await tenantDb('users')
.where({ email })
.first();
if (existingUser) {
console.log(`User ${email} already exists in tenant ${tenantSlug}`);
await tenantDb.destroy();
return;
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
await tenantDb('users').insert({
email,
password: hashedPassword,
firstName,
lastName,
isActive: true,
created_at: new Date(),
updated_at: new Date(),
});
console.log(`\nUser created successfully in tenant ${tenantSlug}!`);
console.log('Email:', email);
console.log('Password:', password);
// Create admin role if it doesn't exist
let adminRole = await tenantDb('roles')
.where({ name: 'admin' })
.first();
if (!adminRole) {
await tenantDb('roles').insert({
name: 'admin',
guardName: 'api',
description: 'Administrator role with full access',
created_at: new Date(),
updated_at: new Date(),
});
adminRole = await tenantDb('roles')
.where({ name: 'admin' })
.first();
console.log('Admin role created');
}
// Get the created user
const user = await tenantDb('users')
.where({ email })
.first();
// Assign admin role to user
if (adminRole && user) {
await tenantDb('user_roles').insert({
userId: user.id,
roleId: adminRole.id,
created_at: new Date(),
updated_at: new Date(),
});
console.log('Admin role assigned to user');
}
await tenantDb.destroy();
} catch (error) {
console.error('Error creating tenant user:', error);
} finally {
await centralPrisma.$disconnect();
}
}
createTenantUser();

View File

@@ -0,0 +1,169 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
import knex, { Knex } from 'knex';
import { createDecipheriv } from 'crypto';
// Encryption configuration - must match the one used in tenant service
const ALGORITHM = 'aes-256-cbc';
/**
* Decrypt a tenant's database password
*/
function decryptPassword(encryptedPassword: string): string {
try {
// Check if password is already plaintext (for legacy/development)
if (!encryptedPassword.includes(':')) {
console.warn('⚠️ Password appears to be unencrypted, using as-is');
return encryptedPassword;
}
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
if (parts.length !== 2) {
throw new Error('Invalid encrypted password format');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Error decrypting password:', error);
throw error;
}
}
/**
* Create a Knex connection for a specific tenant
*/
function createTenantKnexConnection(tenant: any): Knex {
const decryptedPassword = decryptPassword(tenant.dbPassword);
// Use Docker hostname 'db' when running inside container
// The dbHost will be 'db' for Docker connections or 'localhost' for local development
const dbHost = tenant.dbHost;
return knex({
client: 'mysql2',
connection: {
host: dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations/tenant',
},
});
}
/**
* Run migrations for a specific tenant
*/
async function migrateTenant(tenant: any): Promise<void> {
console.log(`\n🔄 Migrating tenant: ${tenant.name} (${tenant.dbName})`);
const tenantKnex = createTenantKnexConnection(tenant);
try {
const [batchNo, log] = await tenantKnex.migrate.latest();
if (log.length === 0) {
console.log(`${tenant.name}: Already up to date`);
} else {
console.log(`${tenant.name}: Ran ${log.length} migrations:`);
log.forEach((migration) => {
console.log(` - ${migration}`);
});
}
} catch (error) {
console.error(`${tenant.name}: Migration failed:`, error);
throw error;
} finally {
await tenantKnex.destroy();
}
}
/**
* Main function to migrate all active tenants
*/
async function migrateAllTenants() {
console.log('🚀 Starting migration for all tenants...\n');
const centralPrisma = new CentralPrismaClient();
try {
// Fetch all active tenants
const tenants = await centralPrisma.tenant.findMany({
where: {
status: 'ACTIVE',
},
orderBy: {
name: 'asc',
},
});
if (tenants.length === 0) {
console.log('⚠️ No active tenants found.');
return;
}
console.log(`📋 Found ${tenants.length} active tenant(s)\n`);
let successCount = 0;
let failureCount = 0;
const failures: { tenant: string; error: string }[] = [];
// Migrate each tenant sequentially
for (const tenant of tenants) {
try {
await migrateTenant(tenant);
successCount++;
} catch (error) {
failureCount++;
failures.push({
tenant: tenant.name,
error: error.message,
});
}
}
// Print summary
console.log('\n' + '='.repeat(60));
console.log('📊 Migration Summary');
console.log('='.repeat(60));
console.log(`✅ Successful: ${successCount}`);
console.log(`❌ Failed: ${failureCount}`);
if (failures.length > 0) {
console.log('\n❌ Failed Tenants:');
failures.forEach(({ tenant, error }) => {
console.log(` - ${tenant}: ${error}`);
});
process.exit(1);
} else {
console.log('\n🎉 All tenant migrations completed successfully!');
}
} catch (error) {
console.error('❌ Fatal error:', error);
process.exit(1);
} finally {
await centralPrisma.$disconnect();
}
}
// Run the migration
migrateAllTenants()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,134 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
import knex, { Knex } from 'knex';
import { createDecipheriv } from 'crypto';
// Encryption configuration
const ALGORITHM = 'aes-256-cbc';
/**
* Decrypt a tenant's database password
*/
function decryptPassword(encryptedPassword: string): string {
try {
// Check if password is already plaintext (for legacy/development)
if (!encryptedPassword.includes(':')) {
console.warn('⚠️ Password appears to be unencrypted, using as-is');
return encryptedPassword;
}
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
if (parts.length !== 2) {
throw new Error('Invalid encrypted password format');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Error decrypting password:', error);
throw error;
}
}
/**
* Create a Knex connection for a specific tenant
*/
function createTenantKnexConnection(tenant: any): Knex {
const decryptedPassword = decryptPassword(tenant.dbPassword);
return knex({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations/tenant',
},
});
}
/**
* Migrate a specific tenant by slug or ID
*/
async function migrateTenant() {
const tenantIdentifier = process.argv[2];
if (!tenantIdentifier) {
console.error('❌ Usage: npm run migrate:tenant <tenant-slug-or-id>');
process.exit(1);
}
console.log(`🔍 Looking for tenant: ${tenantIdentifier}\n`);
const centralPrisma = new CentralPrismaClient();
try {
// Find tenant by slug or ID
const tenant = await centralPrisma.tenant.findFirst({
where: {
OR: [
{ slug: tenantIdentifier },
{ id: tenantIdentifier },
],
},
});
if (!tenant) {
console.error(`❌ Tenant not found: ${tenantIdentifier}`);
process.exit(1);
}
console.log(`📋 Tenant: ${tenant.name} (${tenant.slug})`);
console.log(`📊 Database: ${tenant.dbName}`);
console.log(`🔄 Running migrations...\n`);
const tenantKnex = createTenantKnexConnection(tenant);
try {
const [batchNo, log] = await tenantKnex.migrate.latest();
if (log.length === 0) {
console.log(`✅ Already up to date (batch ${batchNo})`);
} else {
console.log(`✅ Ran ${log.length} migration(s) (batch ${batchNo}):`);
log.forEach((migration) => {
console.log(` - ${migration}`);
});
}
console.log('\n🎉 Migration completed successfully!');
} catch (error) {
console.error('❌ Migration failed:', error.message);
throw error;
} finally {
await tenantKnex.destroy();
}
} catch (error) {
console.error('❌ Fatal error:', error);
process.exit(1);
} finally {
await centralPrisma.$disconnect();
}
}
// Run the migration
migrateTenant()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,181 @@
import { Knex } from 'knex';
import * as knexLib from 'knex';
/**
* Create a Knex connection for tenant database
*/
function createKnexConnection(database: string): Knex {
return knexLib.default({
client: 'mysql2',
connection: {
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '3306'),
user: 'root',
password: 'asjdnfqTash37faggT',
database: database,
},
});
}
interface RoleWithPermissions {
name: string;
description: string;
objectPermissions: {
[objectApiName: string]: {
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canViewAll: boolean;
canModifyAll: boolean;
};
};
}
const DEFAULT_ROLES: RoleWithPermissions[] = [
{
name: 'System Administrator',
description: 'Full access to all objects and records. Can view and modify all data.',
objectPermissions: {
'*': {
canCreate: true,
canRead: true,
canEdit: true,
canDelete: true,
canViewAll: true,
canModifyAll: true,
},
},
},
{
name: 'Standard User',
description: 'Can create, read, edit, and delete own records. Respects OWD settings.',
objectPermissions: {
'*': {
canCreate: true,
canRead: true,
canEdit: true,
canDelete: true,
canViewAll: false,
canModifyAll: false,
},
},
},
{
name: 'Read Only',
description: 'Can only read records based on OWD settings. No create, edit, or delete.',
objectPermissions: {
'*': {
canCreate: false,
canRead: true,
canEdit: false,
canDelete: false,
canViewAll: false,
canModifyAll: false,
},
},
},
];
async function seedRolesForTenant(knex: Knex, tenantName: string) {
console.log(`\n🌱 Seeding roles for tenant: ${tenantName}`);
// Get all object definitions
const objectDefinitions = await knex('object_definitions').select('id', 'apiName');
for (const roleData of DEFAULT_ROLES) {
// Check if role already exists
const existingRole = await knex('roles')
.where({ name: roleData.name })
.first();
let roleId: string;
if (existingRole) {
console.log(` Role "${roleData.name}" already exists, skipping...`);
roleId = existingRole.id;
} else {
// Create role
await knex('roles').insert({
name: roleData.name,
guardName: 'api',
description: roleData.description,
});
// Get the inserted role
const newRole = await knex('roles')
.where({ name: roleData.name })
.first();
roleId = newRole.id;
console.log(` ✅ Created role: ${roleData.name}`);
}
// Create object permissions for all objects
const wildcardPermissions = roleData.objectPermissions['*'];
for (const objectDef of objectDefinitions) {
// Check if permission already exists
const existingPermission = await knex('role_object_permissions')
.where({
roleId: roleId,
objectDefinitionId: objectDef.id,
})
.first();
if (!existingPermission) {
await knex('role_object_permissions').insert({
roleId: roleId,
objectDefinitionId: objectDef.id,
canCreate: wildcardPermissions.canCreate,
canRead: wildcardPermissions.canRead,
canEdit: wildcardPermissions.canEdit,
canDelete: wildcardPermissions.canDelete,
canViewAll: wildcardPermissions.canViewAll,
canModifyAll: wildcardPermissions.canModifyAll,
});
}
}
console.log(` 📋 Set permissions for ${objectDefinitions.length} objects`);
}
}
async function seedAllTenants() {
console.log('🚀 Starting role seeding for all tenants...\n');
// For now, seed the main tenant database
const databases = ['tenant_tenant1'];
let successCount = 0;
let errorCount = 0;
for (const database of databases) {
try {
const knex = createKnexConnection(database);
await seedRolesForTenant(knex, database);
await knex.destroy();
successCount++;
} catch (error) {
console.error(`${database}: Seeding failed:`, error.message);
errorCount++;
}
}
console.log('\n============================================================');
console.log('📊 Seeding Summary');
console.log('============================================================');
console.log(`✅ Successful: ${successCount}`);
console.log(`❌ Failed: ${errorCount}`);
if (errorCount === 0) {
console.log('\n🎉 All tenant roles seeded successfully!');
}
}
seedAllTenants()
.then(() => process.exit(0))
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,332 @@
import { randomUUID } from 'crypto';
import { AiProcess, AiProcessVersion, AiToolConfig } from '../src/models/ai-process.model';
// Bootstrap NestJS to get proper services
async function getTenantContext(tenantSlugOrId: string) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../src/app.module');
const { TenantDatabaseService } = await import('../src/tenant/tenant-database.service');
// Create app context (without listening)
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
const tenantDbService = app.get(TenantDatabaseService);
// Resolve tenant ID
const tenantId = await tenantDbService.resolveTenantId(tenantSlugOrId);
// Get proper Knex connection
const knex = await tenantDbService.getTenantKnexById(tenantId);
return { tenantId, knex, app };
}
/**
* Seed script for demo AI Process: Register New Pet
*
* This process demonstrates:
* - Conditional logic (find or create account/contact)
* - Tool usage (findAccount, createAccount, findContact, createContact, createPet)
* - Sequential execution
* - LLM decision nodes with structured JSON output
*
* Usage:
* npm run seed:demo-process -- <tenant-slug-or-id>
*/
const demoProcessGraph = {
id: 'register_new_pet',
name: 'Register New Pet',
description: 'Complete pet registration workflow with account and contact resolution',
allowCycles: false,
nodes: [
{
id: 'start',
type: 'Start',
position: { x: 250, y: 50 },
data: { label: 'Start' },
},
{
id: 'extract_info',
type: 'LLMDecisionNode',
position: { x: 250, y: 150 },
data: {
label: 'Extract Pet Info',
promptTemplate: `Extract pet registration information from the user message.
User message: {{state.message}}
Extract:
- Pet name (required)
- Pet species (required, e.g., "dog", "cat", "bird")
- Pet breed (optional)
- Pet age (optional, as number)
- Owner first name (required)
- Owner last name (required)
- Owner email (optional)
- Owner phone (optional)
- Account/Company name (optional, defaults to owner's full name)
Return JSON with these exact fields.`,
inputKeys: ['message'],
outputSchema: {
type: 'object',
properties: {
petName: { type: 'string' },
species: { type: 'string' },
breed: { type: 'string' },
age: { type: 'number' },
ownerFirstName: { type: 'string' },
ownerLastName: { type: 'string' },
ownerEmail: { type: 'string' },
ownerPhone: { type: 'string' },
accountName: { type: 'string' },
},
required: ['petName', 'species', 'ownerFirstName', 'ownerLastName'],
},
model: { name: 'gpt-4o', temperature: 0 },
},
},
{
id: 'find_account',
type: 'ToolNode',
position: { x: 250, y: 280 },
data: {
label: 'Find Account',
toolName: 'findAccount',
argsTemplate: {
name: '{{state.accountName}}',
email: '{{state.ownerEmail}}',
},
outputMapping: {
found: 'accountFound',
accountId: 'accountId',
},
},
},
{
id: 'create_account',
type: 'ToolNode',
position: { x: 450, y: 380 },
data: {
label: 'Create Account',
toolName: 'createAccount',
argsTemplate: {
name: '{{state.accountName}}',
email: '{{state.ownerEmail}}',
phone: '{{state.ownerPhone}}',
},
outputMapping: {
accountId: 'accountId',
},
},
},
{
id: 'find_contact',
type: 'ToolNode',
position: { x: 250, y: 480 },
data: {
label: 'Find Contact',
toolName: 'findContact',
argsTemplate: {
firstName: '{{state.ownerFirstName}}',
lastName: '{{state.ownerLastName}}',
email: '{{state.ownerEmail}}',
accountId: '{{state.accountId}}',
},
outputMapping: {
found: 'contactFound',
contactId: 'contactId',
},
},
},
{
id: 'create_contact',
type: 'ToolNode',
position: { x: 450, y: 580 },
data: {
label: 'Create Contact',
toolName: 'createContact',
argsTemplate: {
firstName: '{{state.ownerFirstName}}',
lastName: '{{state.ownerLastName}}',
email: '{{state.ownerEmail}}',
phone: '{{state.ownerPhone}}',
accountId: '{{state.accountId}}',
},
outputMapping: {
contactId: 'contactId',
},
},
},
{
id: 'create_pet',
type: 'ToolNode',
position: { x: 250, y: 680 },
data: {
label: 'Create Pet Record',
toolName: 'createPet',
argsTemplate: {
name: '{{state.petName}}',
species: '{{state.species}}',
breed: '{{state.breed}}',
age: '{{state.age}}',
ownerId: '{{state.contactId}}',
},
outputMapping: {
petId: 'petId',
},
},
},
{
id: 'end',
type: 'End',
position: { x: 250, y: 780 },
data: { label: 'End' },
},
],
edges: [
{ id: 'e1', source: 'start', target: 'extract_info' },
{ id: 'e2', source: 'extract_info', target: 'find_account' },
{
id: 'e3',
source: 'find_account',
target: 'find_contact',
condition: { '==': [{ var: 'accountFound' }, true] },
},
{
id: 'e4',
source: 'find_account',
target: 'create_account',
condition: { '==': [{ var: 'accountFound' }, false] },
},
{ id: 'e5', source: 'create_account', target: 'find_contact' },
{
id: 'e6',
source: 'find_contact',
target: 'create_pet',
condition: { '==': [{ var: 'contactFound' }, true] },
},
{
id: 'e7',
source: 'find_contact',
target: 'create_contact',
condition: { '==': [{ var: 'contactFound' }, false] },
},
{ id: 'e8', source: 'create_contact', target: 'create_pet' },
{ id: 'e9', source: 'create_pet', target: 'end' },
],
};
const demoTools = [
'findAccount',
'createAccount',
'findContact',
'createContact',
'createPet',
];
async function seedDemoProcess(tenantSlugOrId: string) {
let app;
try {
console.log(`\n🌱 Seeding demo AI process for tenant: ${tenantSlugOrId}\n`);
const context = await getTenantContext(tenantSlugOrId);
const { tenantId, knex, app: nestApp } = context;
app = nestApp;
console.log(`✓ Resolved tenant ID: ${tenantId}`);
console.log(`✓ Connected to tenant database`);
// Check if process already exists
const existing = await AiProcess.query(knex)
.where('name', demoProcessGraph.name)
.first();
if (existing) {
console.log(`⚠ Process "${demoProcessGraph.name}" already exists (ID: ${existing.id})`);
console.log(` To create a new version, update via the UI.`);
return;
}
// Create process in transaction
await knex.transaction(async (trx) => {
const processId = randomUUID();
const userId = 'system'; // System user for seed data
// Create process
await AiProcess.query(trx).insert({
id: processId,
name: demoProcessGraph.name,
description: demoProcessGraph.description,
latestVersion: 1,
createdBy: userId,
});
console.log(`✓ Created process: ${demoProcessGraph.name} (${processId})`);
// Create initial version
// Note: In production, this would call the compiler service
// For seed, we're storing a simplified version
await AiProcessVersion.query(trx).insert({
id: randomUUID(),
processId,
version: 1,
graphJson: demoProcessGraph,
compiledJson: {
graphId: demoProcessGraph.id,
version: 1,
nodes: demoProcessGraph.nodes,
edges: demoProcessGraph.edges,
startNodeId: 'start',
endNodeIds: ['end'],
adjacency: {},
},
createdBy: userId,
});
console.log(`✓ Created process version 1`);
// Enable demo tools for tenant
for (const toolName of demoTools) {
const existingTool = await AiToolConfig.query(trx)
.where('tool_name', toolName)
.first();
if (!existingTool) {
await AiToolConfig.query(trx).insert({
id: randomUUID(),
toolName,
enabled: true,
});
console.log(`✓ Enabled tool: ${toolName}`);
}
}
});
console.log(`\n✅ Demo process seeded successfully!\n`);
console.log(`Next steps:`);
console.log(` 1. Navigate to /ai-processes in your frontend`);
console.log(` 2. Open the "${demoProcessGraph.name}" process`);
console.log(` 3. Test it by sending a message like:`);
console.log(` "Register a dog named Max, owned by John Smith (john@email.com)"`);
console.log();
if (app) await app.close();
process.exit(0);
} catch (error) {
console.error('❌ Seed failed:', error);
if (app) await app.close();
process.exit(1);
}
}
// Get tenant from command line args
const tenantSlugOrId = process.argv[2];
if (!tenantSlugOrId) {
console.error('Usage: npm run seed:demo-process -- <tenant-slug-or-id>');
process.exit(1);
}
seedDemoProcess(tenantSlugOrId);

View File

@@ -0,0 +1,72 @@
import { getCentralPrisma } from '../src/prisma/central-prisma.service';
import * as knex from 'knex';
import * as crypto from 'crypto';
function decrypt(text: string): string {
const parts = text.split(':');
const iv = Buffer.from(parts.shift()!, 'hex');
const encryptedText = Buffer.from(parts.join(':'), 'hex');
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
key,
iv,
);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
async function updateNameField() {
const centralPrisma = getCentralPrisma();
try {
// Find tenant1
const tenant = await centralPrisma.tenant.findFirst({
where: {
OR: [
{ id: 'tenant1' },
{ slug: 'tenant1' },
],
},
});
if (!tenant) {
console.error('❌ Tenant tenant1 not found');
process.exit(1);
}
console.log(`📋 Tenant: ${tenant.name} (${tenant.slug})`);
console.log(`📊 Database: ${tenant.dbName}`);
// Decrypt password
const password = decrypt(tenant.dbPassword);
// Create connection
const tenantKnex = knex.default({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: password,
database: tenant.dbName,
},
});
// Update Account object
await tenantKnex('object_definitions')
.where({ apiName: 'Account' })
.update({ nameField: 'name' });
console.log('✅ Updated Account object nameField to "name"');
await tenantKnex.destroy();
await centralPrisma.$disconnect();
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
}
}
updateNameField();

View File

@@ -0,0 +1,147 @@
/**
* Example seed data for Account object with UI metadata
* Run this after migrations to add UI metadata to existing Account fields
*/
exports.seed = async function(knex) {
// Get the Account object
const accountObj = await knex('object_definitions')
.where({ apiName: 'Account' })
.first();
if (!accountObj) {
console.log('Account object not found. Please run migrations first.');
return;
}
console.log(`Found Account object with ID: ${accountObj.id}`);
// Update existing Account fields with UI metadata
const fieldsToUpdate = [
{
apiName: 'name',
ui_metadata: JSON.stringify({
fieldType: 'TEXT',
placeholder: 'Enter account name',
helpText: 'The name of the organization or company',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
section: 'basic',
sectionLabel: 'Basic Information',
sectionOrder: 1,
validationRules: [
{ type: 'required', message: 'Account name is required' },
{ type: 'minLength', value: 2, message: 'Account name must be at least 2 characters' },
{ type: 'maxLength', value: 255, message: 'Account name cannot exceed 255 characters' }
]
})
},
{
apiName: 'website',
ui_metadata: JSON.stringify({
fieldType: 'URL',
placeholder: 'https://www.example.com',
helpText: 'Company website URL',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
section: 'basic',
sectionLabel: 'Basic Information',
sectionOrder: 1,
validationRules: [
{ type: 'url', message: 'Please enter a valid URL' }
]
})
},
{
apiName: 'phone',
ui_metadata: JSON.stringify({
fieldType: 'TEXT',
placeholder: '+1 (555) 000-0000',
helpText: 'Primary phone number',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: false,
section: 'contact',
sectionLabel: 'Contact Information',
sectionOrder: 2,
validationRules: [
{ type: 'pattern', value: '^\\+?[0-9\\s\\-\\(\\)]+$', message: 'Please enter a valid phone number' }
]
})
},
{
apiName: 'industry',
ui_metadata: JSON.stringify({
fieldType: 'SELECT',
placeholder: 'Select industry',
helpText: 'The primary industry this account operates in',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
section: 'details',
sectionLabel: 'Account Details',
sectionOrder: 3,
options: [
{ value: 'technology', label: 'Technology' },
{ value: 'finance', label: 'Finance' },
{ value: 'healthcare', label: 'Healthcare' },
{ value: 'manufacturing', label: 'Manufacturing' },
{ value: 'retail', label: 'Retail' },
{ value: 'education', label: 'Education' },
{ value: 'government', label: 'Government' },
{ value: 'nonprofit', label: 'Non-Profit' },
{ value: 'other', label: 'Other' }
]
})
},
{
apiName: 'ownerId',
ui_metadata: JSON.stringify({
fieldType: 'SELECT',
placeholder: 'Select owner',
helpText: 'The user who owns this account',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
section: 'system',
sectionLabel: 'System Information',
sectionOrder: 4,
// This would be dynamically populated from the users table
// For now, providing static structure
isReference: true,
referenceObject: 'User',
referenceDisplayField: 'name'
})
}
];
// Update each field with UI metadata
for (const fieldUpdate of fieldsToUpdate) {
const result = await knex('field_definitions')
.where({
objectDefinitionId: accountObj.id,
apiName: fieldUpdate.apiName
})
.update({
ui_metadata: fieldUpdate.ui_metadata,
updated_at: knex.fn.now()
});
if (result > 0) {
console.log(`✓ Updated ${fieldUpdate.apiName} with UI metadata`);
} else {
console.log(`✗ Field ${fieldUpdate.apiName} not found`);
}
}
console.log('\n✅ Account fields UI metadata seed completed successfully!');
console.log('You can now fetch the Account object UI config via:');
console.log('GET /api/setup/objects/Account/ui-config');
};

View File

@@ -0,0 +1,349 @@
/**
* Example seed data for Contact object with UI metadata
* Run this after creating the object definition
*/
exports.seed = async function(knex) {
// Get or create the Contact object
const [contactObj] = await knex('object_definitions')
.where({ api_name: 'Contact' })
.select('id');
if (!contactObj) {
console.log('Contact object not found. Please create it first.');
return;
}
// Define fields with UI metadata
const fields = [
{
object_definition_id: contactObj.id,
api_name: 'firstName',
label: 'First Name',
type: 'text',
is_required: true,
is_system: false,
is_custom: false,
display_order: 1,
ui_metadata: {
placeholder: 'Enter first name',
helpText: 'The contact\'s given name',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [
{ type: 'min', value: 2, message: 'First name must be at least 2 characters' },
{ type: 'max', value: 50, message: 'First name cannot exceed 50 characters' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'lastName',
label: 'Last Name',
type: 'text',
is_required: true,
is_system: false,
is_custom: false,
display_order: 2,
ui_metadata: {
placeholder: 'Enter last name',
helpText: 'The contact\'s family name',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [
{ type: 'min', value: 2, message: 'Last name must be at least 2 characters' },
{ type: 'max', value: 50, message: 'Last name cannot exceed 50 characters' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'email',
label: 'Email',
type: 'email',
is_required: true,
is_unique: true,
is_system: false,
is_custom: false,
display_order: 3,
ui_metadata: {
placeholder: 'email@example.com',
helpText: 'Primary email address',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [
{ type: 'email', message: 'Please enter a valid email address' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'phone',
label: 'Phone',
type: 'text',
is_required: false,
is_system: false,
is_custom: false,
display_order: 4,
ui_metadata: {
placeholder: '+1 (555) 000-0000',
helpText: 'Primary phone number',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: false,
validationRules: [
{ type: 'pattern', value: '^\\+?[0-9\\s\\-\\(\\)]+$', message: 'Please enter a valid phone number' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'company',
label: 'Company',
type: 'text',
is_required: false,
is_system: false,
is_custom: false,
display_order: 5,
ui_metadata: {
placeholder: 'Company name',
helpText: 'The organization this contact works for',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
},
{
object_definition_id: contactObj.id,
api_name: 'jobTitle',
label: 'Job Title',
type: 'text',
is_required: false,
is_system: false,
is_custom: false,
display_order: 6,
ui_metadata: {
placeholder: 'e.g., Senior Manager',
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false
}
},
{
object_definition_id: contactObj.id,
api_name: 'status',
label: 'Status',
type: 'picklist',
is_required: true,
is_system: false,
is_custom: false,
display_order: 7,
default_value: 'active',
ui_metadata: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
{ label: 'Archived', value: 'archived' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'leadSource',
label: 'Lead Source',
type: 'picklist',
is_required: false,
is_system: false,
is_custom: false,
display_order: 8,
ui_metadata: {
placeholder: 'Select lead source',
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true,
options: [
{ label: 'Website', value: 'website' },
{ label: 'Referral', value: 'referral' },
{ label: 'Social Media', value: 'social' },
{ label: 'Conference', value: 'conference' },
{ label: 'Cold Call', value: 'cold_call' },
{ label: 'Other', value: 'other' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'isVip',
label: 'VIP Customer',
type: 'boolean',
is_required: false,
is_system: false,
is_custom: false,
display_order: 9,
default_value: 'false',
ui_metadata: {
helpText: 'Mark as VIP for priority support',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
},
{
object_definition_id: contactObj.id,
api_name: 'birthDate',
label: 'Birth Date',
type: 'date',
is_required: false,
is_system: false,
is_custom: false,
display_order: 10,
ui_metadata: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true,
format: 'yyyy-MM-dd'
}
},
{
object_definition_id: contactObj.id,
api_name: 'website',
label: 'Website',
type: 'url',
is_required: false,
is_system: false,
is_custom: false,
display_order: 11,
ui_metadata: {
placeholder: 'https://example.com',
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
validationRules: [
{ type: 'url', message: 'Please enter a valid URL starting with http:// or https://' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'mailingAddress',
label: 'Mailing Address',
type: 'textarea',
is_required: false,
is_system: false,
is_custom: false,
display_order: 12,
ui_metadata: {
placeholder: 'Enter full mailing address',
rows: 3,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false
}
},
{
object_definition_id: contactObj.id,
api_name: 'notes',
label: 'Notes',
type: 'textarea',
is_required: false,
is_system: false,
is_custom: false,
display_order: 13,
ui_metadata: {
placeholder: 'Additional notes about this contact...',
rows: 5,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false
}
},
{
object_definition_id: contactObj.id,
api_name: 'annualRevenue',
label: 'Annual Revenue',
type: 'currency',
is_required: false,
is_system: false,
is_custom: false,
display_order: 14,
ui_metadata: {
prefix: '$',
step: 0.01,
min: 0,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
},
{
object_definition_id: contactObj.id,
api_name: 'numberOfEmployees',
label: 'Number of Employees',
type: 'integer',
is_required: false,
is_system: false,
is_custom: false,
display_order: 15,
ui_metadata: {
min: 1,
step: 1,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
}
];
// Insert or update fields
for (const field of fields) {
const existing = await knex('field_definitions')
.where({
object_definition_id: field.object_definition_id,
api_name: field.api_name
})
.first();
if (existing) {
await knex('field_definitions')
.where({ id: existing.id })
.update({
...field,
ui_metadata: JSON.stringify(field.ui_metadata),
updated_at: knex.fn.now()
});
console.log(`Updated field: ${field.api_name}`);
} else {
await knex('field_definitions').insert({
...field,
ui_metadata: JSON.stringify(field.ui_metadata),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log(`Created field: ${field.api_name}`);
}
}
console.log('Contact fields seeded successfully!');
};

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { AiAssistantService } from './ai-assistant.service';
import { AiChatRequestDto } from './dto/ai-chat.dto';
import { AiSearchRequestDto } from './dto/ai-search.dto';
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiAssistantController {
constructor(private readonly aiAssistantService: AiAssistantService) {}
@Post('chat')
async chat(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: AiChatRequestDto,
) {
return this.aiAssistantService.handleChat(
tenantId,
user.userId,
payload.message,
payload.history,
payload.context,
);
}
@Post('search')
async search(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: AiSearchRequestDto,
) {
return this.aiAssistantService.searchRecords(
tenantId,
user.userId,
payload,
);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AiAssistantController } from './ai-assistant.controller';
import { AiAssistantService } from './ai-assistant.service';
import { ObjectModule } from '../object/object.module';
import { PageLayoutModule } from '../page-layout/page-layout.module';
import { TenantModule } from '../tenant/tenant.module';
import { MeilisearchModule } from '../search/meilisearch.module';
@Module({
imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule],
controllers: [AiAssistantController],
providers: [AiAssistantService],
exports: [AiAssistantService],
})
export class AiAssistantModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
export interface AiChatMessage {
role: 'user' | 'assistant';
text: string;
}
export interface AiChatContext {
objectApiName?: string;
view?: string;
recordId?: string;
route?: string;
}
export interface AiAssistantReply {
reply: string;
action?: 'create_record' | 'collect_fields' | 'clarify';
missingFields?: string[];
record?: any;
}
export interface AiAssistantState {
message: string;
history?: AiChatMessage[];
context: AiChatContext;
objectDefinition?: any;
pageLayout?: any;
extractedFields?: Record<string, any>;
requiredFields?: string[];
missingFields?: string[];
action?: AiAssistantReply['action'];
record?: any;
reply?: string;
}

View File

@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { AiChatMessageDto } from './ai-chat.message.dto';
export class AiChatContextDto {
@IsOptional()
@IsString()
objectApiName?: string;
@IsOptional()
@IsString()
view?: string;
@IsOptional()
@IsString()
recordId?: string;
@IsOptional()
@IsString()
route?: string;
}
export class AiChatRequestDto {
@IsString()
@IsNotEmpty()
message: string;
@IsOptional()
@IsObject()
context?: AiChatContextDto;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AiChatMessageDto)
history?: AiChatMessageDto[];
}

View File

@@ -0,0 +1,10 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class AiChatMessageDto {
@IsIn(['user', 'assistant'])
role: 'user' | 'assistant';
@IsString()
@IsNotEmpty()
text: string;
}

View File

@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
export class AiSearchRequestDto {
@IsString()
@IsNotEmpty()
objectApiName: string;
@IsString()
@IsNotEmpty()
query: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
pageSize?: number;
}

View File

@@ -0,0 +1,25 @@
import { compileProcessGraph, GraphValidationError } from '../ai-processes.compiler';
import { demoRegisterNewPetProcess } from '../demo-process';
describe('ai-processes compiler', () => {
it('throws when missing start node', () => {
const badGraph = {
...demoRegisterNewPetProcess,
nodes: demoRegisterNewPetProcess.nodes.filter((n) => n.type !== 'Start'),
};
expect(() =>
compileProcessGraph(badGraph, { tenantId: 'default', version: 1 }),
).toThrow(GraphValidationError);
});
it('compiles the demo process graph', () => {
const compiled = compileProcessGraph(demoRegisterNewPetProcess, {
tenantId: 'default',
version: 1,
});
expect(compiled.startNodeId).toBe('start');
expect(compiled.endNodeIds).toContain('end');
});
});

View File

@@ -0,0 +1,40 @@
import { compileProcessGraph } from '../ai-processes.compiler';
import { demoRegisterNewPetProcess } from '../demo-process';
import { runCompiledGraph } from '../ai-processes.runner';
import { ToolRegistry } from '../tools/tool-registry';
describe('ai-processes runner', () => {
it('runs the demo process until human input is required', async () => {
const compiled = compileProcessGraph(demoRegisterNewPetProcess, {
tenantId: 'default',
version: 1,
});
const result = await runCompiledGraph({
compiledGraph: compiled,
input: {
accountName: 'Acme Inc',
firstName: 'Jamie',
lastName: 'Doe',
},
toolRegistry: new ToolRegistry(),
toolContext: { tenantId: 'default', userId: 'user-1' },
llmDecision: async (node, state) => {
if (node.id === 'decide_account') {
return { accountAction: 'find', accountName: state.accountName };
}
if (node.id === 'decide_contact') {
return {
contactAction: 'find',
firstName: state.firstName,
lastName: state.lastName,
};
}
return {};
},
});
expect(result.status).toBe('waiting');
expect(result.currentNodeId).toBe('need_pet');
});
});

View File

@@ -0,0 +1,191 @@
import { apply as applyJsonLogic } from 'json-logic-js';
import { createAjv } from './ai-processes.schemas';
import {
CompiledGraph,
ProcessGraphDefinition,
ProcessGraphEdge,
ProcessGraphNode,
} from './ai-processes.types';
import { ToolRegistry } from './tools/tool-registry';
export class GraphValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'GraphValidationError';
}
}
export interface CompileOptions {
tenantId: string;
version: number;
}
export const validateGraphDefinition = (
graph: ProcessGraphDefinition,
tenantId: string,
) => {
const ajv = createAjv();
const validate = ajv.getSchema<ProcessGraphDefinition>('processGraph');
if (!validate) {
throw new GraphValidationError('Graph schema is not registered.');
}
const valid = validate(graph);
if (!valid) {
throw new GraphValidationError(
`Graph schema validation failed: ${ajv.errorsText(validate.errors)}`,
);
}
const startNodes = graph.nodes.filter((node) => node.type === 'Start');
const endNodes = graph.nodes.filter((node) => node.type === 'End');
if (startNodes.length !== 1) {
throw new GraphValidationError('Graph must contain exactly one Start node.');
}
if (endNodes.length < 1) {
throw new GraphValidationError('Graph must contain at least one End node.');
}
const nodeIds = new Set(graph.nodes.map((node) => node.id));
graph.edges.forEach((edge) => {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
throw new GraphValidationError(`Edge ${edge.id} references unknown nodes.`);
}
});
const adjacency = buildAdjacency(graph.edges);
const reachable = new Set<string>();
const queue = [startNodes[0].id];
while (queue.length) {
const current = queue.shift();
if (!current || reachable.has(current)) continue;
reachable.add(current);
(adjacency[current] || []).forEach((neighbor) => queue.push(neighbor));
}
graph.nodes.forEach((node) => {
if (!reachable.has(node.id)) {
throw new GraphValidationError(`Node ${node.id} is not reachable.`);
}
});
if (!graph.allowCycles && hasCycle(graph.nodes, graph.edges)) {
throw new GraphValidationError('Graph contains cycles but allowCycles=false.');
}
const toolRegistry = new ToolRegistry();
const allToolNames = toolRegistry.getAllToolNames();
graph.nodes.forEach((node) => {
if (node.type === 'ToolNode') {
const toolName = (node.data as { toolName?: string }).toolName;
if (!toolName) {
throw new GraphValidationError(
`ToolNode ${node.id} missing toolName configuration.`,
);
}
// Validate tool exists in registry (allowlist check happens at runtime)
if (!allToolNames.includes(toolName)) {
throw new GraphValidationError(
`Tool ${toolName} is not registered in the tool registry.`,
);
}
}
if (node.type === 'LLMDecisionNode') {
const data = node.data as {
promptTemplate?: string;
inputKeys?: string[];
outputSchema?: Record<string, unknown>;
model?: { name?: string; temperature?: number };
};
if (!data.promptTemplate || !data.outputSchema || !data.model?.name) {
throw new GraphValidationError(
`LLMDecisionNode ${node.id} missing required configuration.`,
);
}
}
if (node.type === 'HumanInputNode') {
const data = node.data as {
requiredFieldsSchema?: Record<string, unknown>;
promptToUser?: string;
};
if (!data.requiredFieldsSchema || !data.promptToUser) {
throw new GraphValidationError(
`HumanInputNode ${node.id} missing required configuration.`,
);
}
}
});
graph.edges.forEach((edge) => {
if (edge.condition) {
try {
applyJsonLogic(edge.condition, {});
} catch (error) {
throw new GraphValidationError(
`Edge ${edge.id} has invalid json-logic condition.`,
);
}
}
});
};
export const compileProcessGraph = (
graph: ProcessGraphDefinition,
options: CompileOptions,
): CompiledGraph => {
validateGraphDefinition(graph, options.tenantId);
const startNodeId = graph.nodes.find((node) => node.type === 'Start')?.id;
if (!startNodeId) {
throw new GraphValidationError('Start node missing after validation.');
}
const endNodeIds = graph.nodes
.filter((node) => node.type === 'End')
.map((node) => node.id);
return {
graphId: graph.id,
version: options.version,
nodes: graph.nodes,
edges: graph.edges,
startNodeId,
endNodeIds,
adjacency: buildAdjacency(graph.edges),
allowCycles: graph.allowCycles,
maxIterations: graph.maxIterations,
};
};
const buildAdjacency = (edges: ProcessGraphEdge[]) => {
return edges.reduce<Record<string, string[]>>((acc, edge) => {
if (!acc[edge.source]) {
acc[edge.source] = [];
}
acc[edge.source].push(edge.target);
return acc;
}, {});
};
const hasCycle = (nodes: ProcessGraphNode[], edges: ProcessGraphEdge[]) => {
const adjacency = buildAdjacency(edges);
const visited = new Set<string>();
const stack = new Set<string>();
const visit = (nodeId: string): boolean => {
if (stack.has(nodeId)) return true;
if (visited.has(nodeId)) return false;
visited.add(nodeId);
stack.add(nodeId);
const neighbors = adjacency[nodeId] || [];
for (const neighbor of neighbors) {
if (visit(neighbor)) return true;
}
stack.delete(nodeId);
return false;
};
return nodes.some((node) => visit(node.id));
};

View File

@@ -0,0 +1,144 @@
import {
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
Sse,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { AiProcessesService } from './ai-processes.service';
import { AiProcessesStreamService } from './ai-processes.stream.service';
import { AiProcessesOrchestratorService } from './ai-processes.orchestrator.service';
import { CreateAiProcessDto, UpdateAiProcessDto } from './dto/ai-process.dto';
import { CreateAiRunDto, ResumeAiRunDto } from './dto/ai-run.dto';
import { CreateChatSessionDto, SendChatMessageDto } from './dto/ai-chat.dto';
@Controller('tenants/:tenantId')
@UseGuards(JwtAuthGuard)
export class AiProcessesController {
constructor(
private readonly processesService: AiProcessesService,
private readonly streamService: AiProcessesStreamService,
private readonly orchestratorService: AiProcessesOrchestratorService,
) {}
@Get('ai-processes')
async listProcesses(@TenantId() tenantId: string) {
return this.processesService.listProcesses(tenantId);
}
@Post('ai-processes')
async createProcess(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: CreateAiProcessDto,
) {
return this.processesService.createProcess(
tenantId,
user.userId,
payload.name,
payload.description,
payload.graph,
);
}
@Put('ai-processes/:processId')
async updateProcess(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('processId') processId: string,
@Body() payload: UpdateAiProcessDto,
) {
return this.processesService.createProcessVersion(
tenantId,
user.userId,
processId,
payload.graph,
);
}
@Get('ai-processes/:processId/versions')
async listVersions(
@TenantId() tenantId: string,
@Param('processId') processId: string,
) {
return this.processesService.listProcessVersions(tenantId, processId);
}
@Post('ai-processes/:processId/runs')
async createRun(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('processId') processId: string,
@Body() payload: CreateAiRunDto,
) {
return this.processesService.createRun(
tenantId,
user.userId,
processId,
payload.input,
payload.sessionId,
payload.sessionId
? (event) => this.streamService.emit(payload.sessionId as string, event)
: undefined,
);
}
@Post('ai-runs/:runId/resume')
async resumeRun(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Param('runId') runId: string,
@Body() payload: ResumeAiRunDto,
) {
return this.processesService.resumeRun(
tenantId,
user.userId,
runId,
payload.input,
payload.sessionId,
payload.sessionId
? (event) => this.streamService.emit(payload.sessionId as string, event)
: undefined,
);
}
@Post('ai-chat/sessions')
async createSession(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() _payload: CreateChatSessionDto,
) {
return this.orchestratorService.createSession(tenantId, user.userId);
}
@Post('ai-chat/messages')
@Post('ai-processes/chat/messages')
async sendChatMessage(
@TenantId() tenantId: string,
@CurrentUser() user: any,
@Body() payload: SendChatMessageDto,
) {
return this.orchestratorService.sendMessage(
tenantId,
user.userId,
payload.message,
payload.sessionId,
payload.processId,
payload.history,
payload.context,
);
}
@Sse('ai-chat/stream')
@Sse('ai-processes/stream')
streamChat(@Query('sessionId') sessionId: string) {
return this.streamService.getStream(sessionId);
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TenantModule } from '../tenant/tenant.module';
import { AiAssistantModule } from '../ai-assistant/ai-assistant.module';
import { AiProcessesController } from './ai-processes.controller';
import { AiProcessesService } from './ai-processes.service';
import { AiProcessesStreamService } from './ai-processes.stream.service';
import { AiProcessesOrchestratorService } from './ai-processes.orchestrator.service';
@Module({
imports: [TenantModule, AiAssistantModule],
controllers: [AiProcessesController],
providers: [
AiProcessesService,
AiProcessesStreamService,
AiProcessesOrchestratorService,
],
exports: [AiProcessesService],
})
export class AiProcessesModule {}

View File

@@ -0,0 +1,212 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { AiProcessesService } from './ai-processes.service';
import { AiProcessesStreamService } from './ai-processes.stream.service';
import { AiAssistantService } from '../ai-assistant/ai-assistant.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { AiChatMessage, AiChatSession } from '../models/ai-chat.model';
import { DeepAgentOrchestrator } from './deep-agent.orchestrator';
@Injectable()
export class AiProcessesOrchestratorService {
constructor(
private readonly processesService: AiProcessesService,
private readonly streamService: AiProcessesStreamService,
private readonly tenantDbService: TenantDatabaseService,
private readonly aiAssistantService: AiAssistantService,
) {}
private async getTenantContext(tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return { knex, tenantId: resolvedTenantId };
}
private async createSessionWithContext(
knex: Knex,
tenantId: string,
userId: string,
) {
return AiChatSession.query(knex).insert({
userId,
});
}
async createSession(tenantId: string, userId: string) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
return this.createSessionWithContext(knex, resolvedTenantId, userId);
}
async sendMessage(
tenantId: string,
userId: string,
message: string,
sessionId?: string,
processId?: string,
history?: { role: string; text: string }[],
context?: Record<string, unknown>,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const session = sessionId
? await AiChatSession.query(knex).findById(sessionId)
: await this.createSessionWithContext(knex, resolvedTenantId, userId);
if (!session) {
throw new Error('Chat session not found.');
}
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'user',
content: message,
});
this.streamService.emit(session.id, { type: 'agent_started' });
const processes = await this.processesService.listProcesses(resolvedTenantId);
this.streamService.emit(session.id, {
type: 'processes_listed',
data: { count: processes.length },
});
// If no processes configured, fallback to standard AI assistant
if (!processes.length) {
const response = await this.aiAssistantService.handleChat(
resolvedTenantId,
userId,
message,
(history ?? []) as any,
context ?? {},
);
this.streamService.emit(session.id, {
type: 'final',
data: { reply: response.reply, action: response.action },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: response.reply,
});
return {
sessionId: session.id,
reply: response.reply,
action: response.action,
record: response.record,
};
}
// Get OpenAI credentials from tenant integrations
const credentials = await this.aiAssistantService.getOpenAiConfig(resolvedTenantId);
if (!credentials?.apiKey) {
throw new Error('OpenAI credentials not configured for this tenant');
}
// Create Deep Agent with tenant's credentials
const deepAgent = new DeepAgentOrchestrator(credentials.apiKey, credentials.model);
// Use Deep Agent to select the best process
const processInfos = processes.map((p) => ({
id: p.id,
name: p.name,
description: p.description || undefined,
}));
const selection = await deepAgent.selectProcess(
message,
processInfos,
history as any,
);
// If we need more information or no match, respond with question
if (selection.action === 'need_more_info' || selection.action === 'no_match') {
const reply = selection.question || selection.reasoning ||
'I\'m not sure which process to use. Could you provide more details?';
this.streamService.emit(session.id, {
type: 'final',
data: { reply, needsMoreInfo: true },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: reply,
});
return { sessionId: session.id, reply, needsMoreInfo: true };
}
// Process selected - find it and execute
const selectedProcess = processes.find((p) => p.id === selection.processId);
if (!selectedProcess) {
throw new Error('Selected process not found.');
}
this.streamService.emit(session.id, {
type: 'process_selected',
processId: selectedProcess.id,
version: selectedProcess.latestVersion,
data: { processName: selectedProcess.name, reasoning: selection.reasoning },
});
// Extract inputs from the message
// For now, we'll use a simple approach - just pass the message as input
// In a more sophisticated implementation, we'd use the deep agent to extract structured inputs
const startMessage = await deepAgent.generateStartMessage(
selectedProcess.name,
{ message },
);
this.streamService.emit(session.id, {
type: 'agent_message',
data: { message: startMessage },
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: startMessage,
});
const { run, result } = await this.processesService.createRun(
resolvedTenantId,
userId,
selectedProcess.id,
{ message, context: context || {} },
session.id,
(payload) => this.streamService.emit(session.id, payload),
);
// Emit final event
this.streamService.emit(session.id, {
type: 'final',
data: {
runId: run.id,
status: result.status,
output: result.output,
message: result.status === 'completed'
? '✅ Workflow completed successfully!'
: result.status === 'error'
? `❌ Workflow failed: ${result.error?.message || 'Unknown error'}`
: '⏸️ Workflow paused',
},
});
await AiChatMessage.query(knex).insert({
sessionId: session.id,
role: 'assistant',
content: result.status === 'completed'
? '✅ Workflow completed successfully!'
: result.status === 'error'
? `❌ Workflow failed: ${result.error?.message || 'Unknown error'}`
: '⏸️ Workflow paused',
});
return { sessionId: session.id, runId: run.id, status: result.status };
}
}

View File

@@ -0,0 +1,222 @@
import { apply as applyJsonLogic } from 'json-logic-js';
import Ajv from 'ajv';
import { ToolRegistry, ToolContext } from './tools/tool-registry';
import {
AiProcessEventPayload,
CompiledGraph,
ProcessGraphNode,
} from './ai-processes.types';
export interface RunOptions {
compiledGraph: CompiledGraph;
input: Record<string, unknown>;
toolRegistry: ToolRegistry;
toolContext: ToolContext;
onEvent?: (event: AiProcessEventPayload) => void;
llmDecision: (
node: ProcessGraphNode,
state: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
}
export interface RunResult {
status: 'running' | 'waiting' | 'completed' | 'error';
state: Record<string, unknown>;
currentNodeId?: string;
output?: Record<string, unknown>;
error?: Record<string, unknown>;
}
export const runCompiledGraph = async (
options: RunOptions,
startNodeId?: string,
): Promise<RunResult> => {
const {
compiledGraph,
input,
toolRegistry,
toolContext,
onEvent,
llmDecision,
} = options;
const state: Record<string, unknown> = { ...input };
let currentNodeId = startNodeId ?? compiledGraph.startNodeId;
let iterations = 0;
const maxIterations = compiledGraph.maxIterations ?? 50;
const emit = (payload: AiProcessEventPayload) => {
if (onEvent) {
onEvent(payload);
}
};
while (currentNodeId) {
if (
compiledGraph.nodes.length > 0 &&
compiledGraph.endNodeIds.includes(currentNodeId)
) {
emit({ type: 'node_started', nodeId: currentNodeId });
emit({ type: 'node_completed', nodeId: currentNodeId });
emit({ type: 'final', data: { output: state } });
return { status: 'completed', state, output: state };
}
const node = compiledGraph.nodes.find((item) => item.id === currentNodeId);
if (!node) {
return {
status: 'error',
state,
error: { message: `Node ${currentNodeId} not found.` },
};
}
emit({ type: 'node_started', nodeId: node.id });
if (node.type === 'LLMDecisionNode') {
const output = await llmDecision(node, state);
validateNodeOutput(node, output);
Object.assign(state, output);
}
if (node.type === 'ToolNode') {
const toolName = (node.data as { toolName: string }).toolName;
emit({ type: 'tool_called', nodeId: node.id, toolName });
const tool = toolRegistry.getTool(toolName);
const argsTemplate = (node.data as { argsTemplate: Record<string, unknown> })
.argsTemplate;
const resolvedArgs = resolveTemplate(argsTemplate, state);
// Debug logging
console.log(`[ToolNode ${node.id}] Tool: ${toolName}`);
console.log(`[ToolNode ${node.id}] State keys:`, Object.keys(state));
console.log(`[ToolNode ${node.id}] ArgsTemplate:`, JSON.stringify(argsTemplate));
console.log(`[ToolNode ${node.id}] ResolvedArgs:`, JSON.stringify(resolvedArgs));
const toolResult = await tool(toolContext, {
...resolvedArgs,
state,
});
console.log(`[ToolNode ${node.id}] ToolResult:`, JSON.stringify(toolResult));
const outputMapping = (node.data as { outputMapping: Record<string, string> })
.outputMapping;
Object.entries(outputMapping).forEach(([key, path]) => {
console.log(`[ToolNode ${node.id}] Mapping: toolResult['${key}'] = ${toolResult[key]} -> state['${path}']`);
state[path] = toolResult[key];
});
}
if (node.type === 'HumanInputNode') {
const data = node.data as {
requiredFieldsSchema: Record<string, unknown>;
promptToUser: string;
};
emit({
type: 'need_input',
nodeId: node.id,
data: {
requiredFieldsSchema: data.requiredFieldsSchema,
promptToUser: data.promptToUser,
},
});
return { status: 'waiting', state, currentNodeId: node.id };
}
emit({ type: 'node_completed', nodeId: node.id });
const nextTargets = compiledGraph.edges.filter(
(edge) => edge.source === node.id,
);
if (nextTargets.length === 0) {
return {
status: 'error',
state,
error: { message: `No outgoing edges for node ${node.id}.` },
};
}
const selectedEdge = selectEdge(nextTargets, state);
if (!selectedEdge) {
return {
status: 'error',
state,
error: { message: `No edge conditions matched for node ${node.id}.` },
};
}
currentNodeId = selectedEdge.target;
iterations += 1;
if (!compiledGraph.allowCycles && iterations > compiledGraph.nodes.length) {
return {
status: 'error',
state,
error: { message: 'Cycle detected during execution.' },
};
}
if (compiledGraph.allowCycles && iterations > maxIterations) {
return {
status: 'error',
state,
error: { message: 'Max iterations exceeded.' },
};
}
}
return { status: 'completed', state, output: state };
};
const resolveTemplate = (
template: Record<string, unknown>,
state: Record<string, unknown>,
) => {
return Object.entries(template).reduce<Record<string, unknown>>(
(acc, [key, value]) => {
if (typeof value === 'string' && value.startsWith('{{state.')) {
const path = value.replace('{{state.', '').replace('}}', '');
acc[key] = state[path];
} else {
acc[key] = value;
}
return acc;
},
{},
);
};
const selectEdge = (
edges: { condition?: Record<string, unknown>; target: string }[],
state: Record<string, unknown>,
) => {
if (edges.length === 1) return edges[0];
return edges.find((edge) => {
if (!edge.condition) return true;
try {
return Boolean(applyJsonLogic(edge.condition, state));
} catch (error) {
return false;
}
});
};
const validateNodeOutput = (
node: ProcessGraphNode,
output: Record<string, unknown>,
) => {
const schema = (node.data as { outputSchema?: Record<string, unknown> })
.outputSchema;
if (!schema) return;
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema);
if (!validate(output)) {
const errors = validate.errors?.map(e => `${e.instancePath} ${e.message}`).join(', ');
throw new Error(
`LLM output invalid for node ${node.id}. Errors: ${errors}. Output: ${JSON.stringify(output)}`
);
}
};

View File

@@ -0,0 +1,79 @@
import Ajv, { JSONSchemaType } from 'ajv';
import addFormats from 'ajv-formats';
import {
AiNodeType,
ProcessGraphDefinition,
ProcessGraphEdge,
ProcessGraphNode,
} from './ai-processes.types';
const nodeTypes: AiNodeType[] = [
'Start',
'LLMDecisionNode',
'ToolNode',
'HumanInputNode',
'End',
];
export const graphSchema: any = {
type: 'object',
required: ['id', 'name', 'nodes', 'edges'],
additionalProperties: false,
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
allowCycles: { type: 'boolean', nullable: true },
maxIterations: { type: 'number', nullable: true },
nodes: {
type: 'array',
items: { $ref: '#/definitions/processGraphNode' },
minItems: 1,
},
edges: {
type: 'array',
items: { $ref: '#/definitions/processGraphEdge' },
minItems: 0,
},
},
definitions: {
processGraphEdge: {
type: 'object',
required: ['id', 'source', 'target'],
additionalProperties: false,
properties: {
id: { type: 'string' },
source: { type: 'string' },
target: { type: 'string' },
condition: { type: 'object', nullable: true },
},
},
processGraphNode: {
type: 'object',
required: ['id', 'type', 'data'],
additionalProperties: false,
properties: {
id: { type: 'string' },
type: { type: 'string', enum: nodeTypes },
position: {
type: 'object',
nullable: true,
required: ['x', 'y'],
additionalProperties: false,
properties: {
x: { type: 'number' },
y: { type: 'number' },
},
},
data: { type: 'object' },
},
},
},
};
export const createAjv = () => {
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
ajv.addSchema(graphSchema, 'processGraph');
return ajv;
};

View File

@@ -0,0 +1,319 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Knex } from 'knex';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import {
AiAuditEvent,
AiProcess,
AiProcessRun,
AiProcessVersion,
} from '../models/ai-process.model';
import { compileProcessGraph } from './ai-processes.compiler';
import { runCompiledGraph } from './ai-processes.runner';
import {
AiProcessEventPayload,
CompiledGraph,
ProcessGraphDefinition,
} from './ai-processes.types';
import { ToolRegistry } from './tools/tool-registry';
import { demoTools } from './tools/demo-tools';
@Injectable()
export class AiProcessesService {
constructor(private readonly tenantDbService: TenantDatabaseService) {}
private async getTenantContext(tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return { knex, tenantId: resolvedTenantId };
}
async listProcesses(tenantId: string) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
return AiProcess.query(knex)
.withGraphFetched('versions')
.orderBy('created_at', 'desc');
}
async getProcess(tenantId: string, processId: string) {
const { knex } = await this.getTenantContext(tenantId);
return AiProcess.query(knex)
.findById(processId)
.withGraphFetched('versions');
}
async createProcess(
tenantId: string,
userId: string,
name: string,
description: string | undefined,
graph: ProcessGraphDefinition,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const compiled = compileProcessGraph(graph, {
tenantId: resolvedTenantId,
version: 1,
});
return knex.transaction(async (trx) => {
const processId = randomUUID();
await AiProcess.query(trx).insert({
id: processId,
name,
description,
latestVersion: 1,
createdBy: userId,
});
await trx('ai_process_versions').insert({
id: randomUUID(),
process_id: processId,
version: 1,
graph_json: JSON.stringify(graph),
compiled_json: JSON.stringify(compiled),
created_by: userId,
created_at: new Date(),
});
return AiProcess.query(trx)
.findById(processId)
.withGraphFetched('versions');
});
}
async createProcessVersion(
tenantId: string,
userId: string,
processId: string,
graph: ProcessGraphDefinition,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const process = await AiProcess.query(knex).findById(processId);
if (!process) {
throw new Error('Process not found.');
}
const nextVersion = process.latestVersion + 1;
const compiled = compileProcessGraph(graph, {
tenantId: resolvedTenantId,
version: nextVersion,
});
return knex.transaction(async (trx) => {
await AiProcess.query(trx)
.findById(processId)
.patch({ latestVersion: nextVersion });
const versionId = randomUUID();
await trx('ai_process_versions').insert({
id: versionId,
process_id: processId,
version: nextVersion,
graph_json: JSON.stringify(graph),
compiled_json: JSON.stringify(compiled),
created_by: userId,
created_at: new Date(),
});
return AiProcessVersion.query(trx).findById(versionId);
});
}
async listProcessVersions(tenantId: string, processId: string) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
return AiProcessVersion.query(knex)
.where({ process_id: processId })
.orderBy('version', 'desc');
}
async createRun(
tenantId: string,
userId: string,
processId: string,
input: Record<string, unknown>,
sessionId: string | undefined,
emitEvent?: (payload: AiProcessEventPayload) => void,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const process = await AiProcess.query(knex).findById(processId);
if (!process) {
throw new Error('Process not found.');
}
const versionRecord = await AiProcessVersion.query(knex).findOne({
process_id: processId,
version: process.latestVersion,
});
if (!versionRecord) {
throw new Error('Process version not found.');
}
const runId = randomUUID();
await AiProcessRun.query(knex).insert({
id: runId,
processId,
version: versionRecord.version,
status: 'running',
inputJson: input,
stateJson: input,
currentNodeId: null,
});
const run = await AiProcessRun.query(knex).findById(runId);
if (!run) {
throw new Error('Run not created.');
}
const compiled = versionRecord.compiledJson as unknown as CompiledGraph;
const toolRegistry = new ToolRegistry(demoTools);
await toolRegistry.loadTenantAllowlist(resolvedTenantId, knex);
const emitAndAudit = (event: AiProcessEventPayload) => {
emitEvent?.(event);
void AiAuditEvent.query(knex).insert({
id: randomUUID(),
runId,
eventType: event.type,
payloadJson: event as any,
});
};
const result = await runCompiledGraph(
{
compiledGraph: compiled,
input,
toolRegistry,
toolContext: { tenantId: resolvedTenantId, userId, knex },
onEvent: (event) => emitAndAudit({ ...event, runId, sessionId }),
llmDecision: async (node, state) =>
this.mockDecision(node.id, state),
},
run.currentNodeId ?? undefined,
);
const updatedRun = await this.persistRunResult(runId, result, knex);
return { run: updatedRun, result };
}
async resumeRun(
tenantId: string,
userId: string,
runId: string,
input: Record<string, unknown>,
sessionId: string | undefined,
emitEvent?: (payload: AiProcessEventPayload) => void,
) {
const { knex, tenantId: resolvedTenantId } =
await this.getTenantContext(tenantId);
const run = await AiProcessRun.query(knex).findById(runId);
if (!run) {
throw new Error('Run not found.');
}
const versionRecord = await AiProcessVersion.query(knex).findOne({
process_id: run.processId,
version: run.version,
});
if (!versionRecord) {
throw new Error('Process version not found.');
}
const compiled = versionRecord.compiledJson as unknown as CompiledGraph;
const toolRegistry = new ToolRegistry(demoTools);
await toolRegistry.loadTenantAllowlist(resolvedTenantId, knex);
const mergedState = { ...(run.stateJson || {}), ...input };
const emitAndAudit = (event: AiProcessEventPayload) => {
emitEvent?.(event);
void AiAuditEvent.query(knex).insert({
id: randomUUID(),
runId: run.id,
eventType: event.type,
payloadJson: event as any,
});
};
const result = await runCompiledGraph(
{
compiledGraph: compiled,
input: mergedState,
toolRegistry,
toolContext: { tenantId: resolvedTenantId, userId, knex },
onEvent: (event) =>
emitAndAudit({ ...event, runId: run.id, sessionId }),
llmDecision: async (node, state) =>
this.mockDecision(node.id, state),
},
run.currentNodeId ?? undefined,
);
const updatedRun = await this.persistRunResult(run.id, result, knex);
return { run: updatedRun, result };
}
private async persistRunResult(runId: string, result: any, knex: Knex) {
const endedAt =
result.status === 'completed' || result.status === 'error'
? new Date()
: null;
return AiProcessRun.query(knex).patchAndFetchById(runId, {
status: result.status,
outputJson: result.output,
errorJson: result.error,
stateJson: result.state,
currentNodeId: result.currentNodeId ?? null,
endedAt,
});
}
private async mockDecision(
nodeId: string,
state: Record<string, unknown>,
) {
if (nodeId === 'extract_info') {
// Extract pet registration info from the message
const message = (state.message as string) || '';
// Simple extraction (in production, this would use an LLM)
const petNameMatch = message.match(/(?:dog|cat|pet)\s+named\s+(\w+)/i);
const petTypeMatch = message.match(/(dog|cat)/i);
const ownerNameMatch = message.match(/owned\s+by\s+([\w\s]+?)(?:\s*\(|$)/i);
const emailMatch = message.match(/\(?([\w\.-]+@[\w\.-]+\.\w+)\)?/i);
const ownerName = ownerNameMatch?.[1]?.trim() || 'Unknown Owner';
const nameParts = ownerName.split(/\s+/);
const firstName = nameParts[0] || 'Unknown';
const lastName = nameParts.slice(1).join(' ') || 'Owner';
return {
petName: petNameMatch?.[1] || 'Unknown Pet',
species: petTypeMatch?.[1]?.toLowerCase() || 'dog',
ownerFirstName: firstName,
ownerLastName: lastName,
ownerEmail: emailMatch?.[1] || null,
accountName: `${firstName} ${lastName}`,
};
}
if (nodeId === 'decide_account') {
const accountName = (state.accountName as string) ?? 'New Account';
const accountAction = state.accountId ? 'find' : 'create';
return { accountAction, accountName };
}
if (nodeId === 'decide_contact') {
const firstName = (state.firstName as string) ?? 'Jane';
const lastName = (state.lastName as string) ?? 'Doe';
const contactAction = state.contactId ? 'find' : 'create';
return { contactAction, firstName, lastName };
}
return {};
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { MessageEvent } from '@nestjs/common';
import { Observable, Subject } from 'rxjs';
import { AiProcessEventPayload } from './ai-processes.types';
@Injectable()
export class AiProcessesStreamService {
private readonly streams = new Map<string, Subject<MessageEvent>>();
getStream(sessionId: string): Observable<MessageEvent> {
return this.getSubject(sessionId).asObservable();
}
emit(sessionId: string, payload: AiProcessEventPayload) {
const subject = this.getSubject(sessionId);
subject.next({ type: payload.type, data: payload });
}
close(sessionId: string) {
const subject = this.streams.get(sessionId);
if (subject) {
subject.complete();
this.streams.delete(sessionId);
}
}
private getSubject(sessionId: string) {
if (!this.streams.has(sessionId)) {
this.streams.set(sessionId, new Subject<MessageEvent>());
}
return this.streams.get(sessionId) as Subject<MessageEvent>;
}
}

View File

@@ -0,0 +1,125 @@
import { JSONSchema7 } from 'json-schema';
export type AiNodeType =
| 'Start'
| 'LLMDecisionNode'
| 'ToolNode'
| 'HumanInputNode'
| 'End';
export interface ProcessGraphDefinition {
id: string;
name: string;
description?: string;
allowCycles?: boolean;
maxIterations?: number;
nodes: ProcessGraphNode[];
edges: ProcessGraphEdge[];
}
export interface ProcessGraphNode {
id: string;
type: AiNodeType;
position?: { x: number; y: number };
data:
| StartNodeData
| LLMDecisionNodeData
| ToolNodeData
| HumanInputNodeData
| EndNodeData;
}
export interface ProcessGraphEdge {
id: string;
source: string;
target: string;
condition?: JsonLogicExpression;
}
export type JsonLogicExpression = Record<string, unknown>;
export interface StartNodeData {
label?: string;
}
export interface EndNodeData {
label?: string;
}
export interface LLMDecisionNodeData {
label?: string;
promptTemplate: string;
inputKeys: string[];
outputSchema: JSONSchema7;
model: {
name: string;
temperature: number;
};
}
export interface ToolNodeData {
label?: string;
toolName: string;
argsTemplate: Record<string, unknown>;
outputMapping: Record<string, string>;
}
export interface HumanInputNodeData {
label?: string;
requiredFieldsSchema: JSONSchema7;
promptToUser: string;
}
export interface CompiledGraph {
graphId: string;
version: number;
nodes: ProcessGraphNode[];
edges: ProcessGraphEdge[];
startNodeId: string;
endNodeIds: string[];
adjacency: Record<string, string[]>;
allowCycles?: boolean;
maxIterations?: number;
}
export type AiProcessStatus = 'running' | 'waiting' | 'completed' | 'error';
export interface AiProcessRunContext {
state: Record<string, unknown>;
currentNodeId?: string;
iterationCount?: number;
}
export type AiProcessEventType =
| 'agent_started'
| 'processes_listed'
| 'process_selected'
| 'agent_message'
| 'node_started'
| 'tool_called'
| 'node_completed'
| 'need_input'
| 'final'
| 'error';
export interface AiProcessEventPayload {
type: AiProcessEventType;
runId?: string;
sessionId?: string;
nodeId?: string;
toolName?: string;
processId?: string;
version?: number;
data?: Record<string, unknown>;
}
export interface NeedInputPayload {
runId: string;
requiredFieldsSchema: JSONSchema7;
promptToUser: string;
}
export interface ProcessSelection {
processId: string;
version: number;
}

View File

@@ -0,0 +1,202 @@
import { ChatOpenAI } from '@langchain/openai';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
export interface ProcessInfo {
id: string;
name: string;
description?: string;
}
export interface ProcessSelectionResult {
action: 'select_process' | 'need_more_info' | 'no_match';
processId?: string;
question?: string;
reasoning?: string;
}
export interface InputExtractionResult {
hasAllInputs: boolean;
extractedInputs: Record<string, unknown>;
missingFields?: string[];
question?: string;
}
export class DeepAgentOrchestrator {
private model: ChatOpenAI;
constructor(
apiKey: string,
modelName: string = 'gpt-4o',
temperature: number = 0,
) {
this.model = new ChatOpenAI({
apiKey,
modelName,
temperature,
});
}
/**
* Step 1: Select the best matching process from available processes
*/
async selectProcess(
userMessage: string,
availableProcesses: ProcessInfo[],
conversationHistory?: { role: string; text: string }[],
): Promise<ProcessSelectionResult> {
const processList = availableProcesses
.map((p) => `- ${p.name} (ID: ${p.id}): ${p.description || 'No description'}`)
.join('\n');
const historyContext =
conversationHistory && conversationHistory.length > 0
? `\n\nConversation history:\n${conversationHistory
.map((msg) => `${msg.role}: ${msg.text}`)
.join('\n')}`
: '';
const systemPrompt = `You are an intelligent process orchestrator. Your task is to select the most appropriate business process based on the user's request.
Available processes:
${processList}
Rules:
1. Select exactly ONE process that best matches the user's intent
2. If the request is ambiguous or matches multiple processes, ask for clarification
3. If no process matches, indicate no match
4. Always provide reasoning for your decision
Respond with JSON:
{
"action": "select_process" | "need_more_info" | "no_match",
"processId": "selected process ID or null",
"question": "clarifying question if needed",
"reasoning": "brief explanation of decision"
}`;
const userPrompt = `User request: ${userMessage}${historyContext}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
const parser = new JsonOutputParser<ProcessSelectionResult>();
const content = response.content as string;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return await parser.parse(jsonMatch[0]);
}
return {
action: 'no_match',
reasoning: 'Failed to parse LLM response',
};
} catch (error: any) {
console.error('Process selection error:', error);
return {
action: 'no_match',
reasoning: `Error: ${error.message}`,
};
}
}
/**
* Step 2: Extract required inputs from user message
*/
async extractInputs(
userMessage: string,
requiredFields: { name: string; description: string; required: boolean }[],
conversationHistory?: { role: string; text: string }[],
context?: Record<string, unknown>,
): Promise<InputExtractionResult> {
const fieldsList = requiredFields
.map((f) => `- ${f.name} (${f.required ? 'required' : 'optional'}): ${f.description}`)
.join('\n');
const historyContext =
conversationHistory && conversationHistory.length > 0
? `\n\nConversation history:\n${conversationHistory
.map((msg) => `${msg.role}: ${msg.text}`)
.join('\n')}`
: '';
const contextInfo = context ? `\n\nAvailable context: ${JSON.stringify(context)}` : '';
const systemPrompt = `You are an input extraction assistant. Extract structured data from the user's message and conversation history.
Required fields for this process:
${fieldsList}${contextInfo}
Rules:
1. Extract as many fields as possible from the message and context
2. Only mark hasAllInputs=true if ALL required fields are present
3. If required fields are missing, generate a natural question to ask the user
4. Use context data when available (e.g., current page context)
Respond with JSON:
{
"hasAllInputs": true | false,
"extractedInputs": { "field1": "value1", ... },
"missingFields": ["field1", "field2"] or undefined,
"question": "natural language question" or undefined
}`;
const userPrompt = `User message: ${userMessage}${historyContext}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
const parser = new JsonOutputParser<InputExtractionResult>();
const content = response.content as string;
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return await parser.parse(jsonMatch[0]);
}
return {
hasAllInputs: false,
extractedInputs: {},
missingFields: requiredFields.filter((f) => f.required).map((f) => f.name),
question: 'I need more information to proceed. Could you provide additional details?',
};
} catch (error: any) {
console.error('Input extraction error:', error);
return {
hasAllInputs: false,
extractedInputs: {},
question: 'I encountered an error processing your request. Please try again.',
};
}
}
/**
* Step 3: Generate a friendly response explaining what will happen
*/
async generateStartMessage(
processName: string,
extractedInputs: Record<string, unknown>,
): Promise<string> {
const systemPrompt = `You are a friendly assistant explaining what process will be executed. Be concise and clear.`;
const userPrompt = `Generate a brief message (1-2 sentences) confirming that you will execute the "${processName}" process with these inputs: ${JSON.stringify(extractedInputs)}`;
try {
const response = await this.model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
]);
return (response.content as string).trim();
} catch (error) {
return `I'll execute the ${processName} process with your provided information.`;
}
}
}

View File

@@ -0,0 +1,173 @@
import { ProcessGraphDefinition } from './ai-processes.types';
export const demoRegisterNewPetProcess: ProcessGraphDefinition = {
id: 'register_new_pet',
name: 'Register New Pet',
description: 'Resolve account/contact then create pet.',
allowCycles: false,
nodes: [
{
id: 'start',
type: 'Start',
data: { label: 'Start' },
},
{
id: 'decide_account',
type: 'LLMDecisionNode',
data: {
label: 'Decide Account Action',
promptTemplate:
'Decide whether to find or create an account. Return JSON {"accountAction":"find|create","accountName":"string"}.',
inputKeys: ['accountName'],
outputSchema: {
type: 'object',
required: ['accountAction', 'accountName'],
properties: {
accountAction: { type: 'string', enum: ['find', 'create'] },
accountName: { type: 'string' },
},
additionalProperties: false,
},
model: { name: 'gpt-4o-mini', temperature: 0 },
},
},
{
id: 'find_account',
type: 'ToolNode',
data: {
label: 'Find Account',
toolName: 'findAccount',
argsTemplate: { accountName: '{{state.accountName}}' },
outputMapping: { accountId: 'accountId', found: 'accountFound' },
},
},
{
id: 'create_account',
type: 'ToolNode',
data: {
label: 'Create Account',
toolName: 'createAccount',
argsTemplate: { accountName: '{{state.accountName}}' },
outputMapping: { accountId: 'accountId' },
},
},
{
id: 'decide_contact',
type: 'LLMDecisionNode',
data: {
label: 'Decide Contact Action',
promptTemplate:
'Decide whether to find or create a contact. Return JSON {"contactAction":"find|create","firstName":"string","lastName":"string"}.',
inputKeys: ['firstName', 'lastName'],
outputSchema: {
type: 'object',
required: ['contactAction', 'firstName', 'lastName'],
properties: {
contactAction: { type: 'string', enum: ['find', 'create'] },
firstName: { type: 'string' },
lastName: { type: 'string' },
},
additionalProperties: false,
},
model: { name: 'gpt-4o-mini', temperature: 0 },
},
},
{
id: 'find_contact',
type: 'ToolNode',
data: {
label: 'Find Contact',
toolName: 'findContact',
argsTemplate: {
accountId: '{{state.accountId}}',
firstName: '{{state.firstName}}',
lastName: '{{state.lastName}}',
},
outputMapping: { contactId: 'contactId', found: 'contactFound' },
},
},
{
id: 'create_contact',
type: 'ToolNode',
data: {
label: 'Create Contact',
toolName: 'createContact',
argsTemplate: {
accountId: '{{state.accountId}}',
firstName: '{{state.firstName}}',
lastName: '{{state.lastName}}',
},
outputMapping: { contactId: 'contactId' },
},
},
{
id: 'need_pet',
type: 'HumanInputNode',
data: {
label: 'Collect Pet Info',
promptToUser: 'What is the pet name and type?',
requiredFieldsSchema: {
type: 'object',
required: ['petName', 'petType'],
properties: {
petName: { type: 'string' },
petType: { type: 'string' },
},
additionalProperties: false,
},
},
},
{
id: 'create_pet',
type: 'ToolNode',
data: {
label: 'Create Pet',
toolName: 'createPet',
argsTemplate: {
contactId: '{{state.contactId}}',
petName: '{{state.petName}}',
petType: '{{state.petType}}',
},
outputMapping: { petId: 'petId' },
},
},
{
id: 'end',
type: 'End',
data: { label: 'End' },
},
],
edges: [
{ id: 'e_start_account', source: 'start', target: 'decide_account' },
{
id: 'e_account_find',
source: 'decide_account',
target: 'find_account',
condition: { '==': [{ var: 'accountAction' }, 'find'] },
},
{
id: 'e_account_create',
source: 'decide_account',
target: 'create_account',
condition: { '==': [{ var: 'accountAction' }, 'create'] },
},
{ id: 'e_account_to_contact', source: 'find_account', target: 'decide_contact' },
{ id: 'e_create_account_to_contact', source: 'create_account', target: 'decide_contact' },
{
id: 'e_contact_find',
source: 'decide_contact',
target: 'find_contact',
condition: { '==': [{ var: 'contactAction' }, 'find'] },
},
{
id: 'e_contact_create',
source: 'decide_contact',
target: 'create_contact',
condition: { '==': [{ var: 'contactAction' }, 'create'] },
},
{ id: 'e_contact_to_pet', source: 'find_contact', target: 'need_pet' },
{ id: 'e_create_contact_to_pet', source: 'create_contact', target: 'need_pet' },
{ id: 'e_need_pet_to_create', source: 'need_pet', target: 'create_pet' },
{ id: 'e_pet_to_end', source: 'create_pet', target: 'end' },
],
};

View File

@@ -0,0 +1,28 @@
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
export class CreateChatSessionDto {
@IsOptional()
@IsString()
context?: string;
}
export class SendChatMessageDto {
@IsString()
message!: string;
@IsOptional()
@IsArray()
history?: { role: string; text: string }[];
@IsOptional()
@IsObject()
context?: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
@IsOptional()
@IsString()
processId?: string;
}

View File

@@ -0,0 +1,24 @@
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
import { ProcessGraphDefinition } from '../ai-processes.types';
export class CreateAiProcessDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsObject()
graph!: ProcessGraphDefinition;
}
export class UpdateAiProcessDto {
@IsObject()
graph!: ProcessGraphDefinition;
}
export class AiProcessListResponseDto {
@IsArray()
items!: Record<string, unknown>[];
}

View File

@@ -0,0 +1,19 @@
import { IsObject, IsOptional, IsString } from 'class-validator';
export class CreateAiRunDto {
@IsObject()
input!: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
}
export class ResumeAiRunDto {
@IsObject()
input!: Record<string, unknown>;
@IsOptional()
@IsString()
sessionId?: string;
}

View File

@@ -0,0 +1,226 @@
import { ToolContext, ToolHandler } from './tool-registry';
import { Account } from '../../models/account.model';
import { Contact } from '../../models/contact.model';
import { randomUUID } from 'crypto';
/**
* Demo tools that wrap ObjectService operations
* These tools provide structured access to CRM entities
*/
export const findAccount: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for findAccount');
}
const { name } = args as { name?: string };
if (!name) {
return { found: false, accountId: null, message: 'Name required' };
}
try {
const query = Account.query(ctx.knex).where('name', 'like', `%${name}%`);
const account = await query.first();
if (account) {
return {
found: true,
accountId: account.id,
account: {
id: account.id,
name: account.name,
},
};
}
return { found: false, accountId: null };
} catch (error: any) {
return { found: false, error: error.message };
}
};
export const createAccount: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createAccount');
}
const { name, email, phone, industry } = args as {
name: string;
email?: string;
phone?: string;
industry?: string;
};
if (!name) {
throw new Error('Account name is required');
}
try {
const accountId = randomUUID();
await ctx.knex('accounts').insert({
id: accountId,
name,
phone,
industry,
ownerId: ctx.userId,
});
return {
success: true,
accountId,
account: {
id: accountId,
name,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
export const findContact: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for findContact');
}
const { firstName, lastName, accountId } = args as {
firstName?: string;
lastName?: string;
accountId?: string;
};
if (!firstName && !lastName) {
return {
found: false,
contactId: null,
message: 'First name or last name required',
};
}
try {
let query = Contact.query(ctx.knex);
if (firstName) {
query = query.where('firstName', 'like', `%${firstName}%`);
}
if (lastName) {
query = query.where('lastName', 'like', `%${lastName}%`);
}
if (accountId) {
query = query.where('accountId', accountId);
}
const contact = await query.first();
if (contact) {
return {
found: true,
contactId: contact.id,
contact: {
id: contact.id,
firstName: contact.firstName,
lastName: contact.lastName,
accountId: contact.accountId,
},
};
}
return { found: false, contactId: null };
} catch (error: any) {
return { found: false, error: error.message };
}
};
export const createContact: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createContact');
}
const { firstName, lastName, email, phone, accountId } = args as {
firstName: string;
lastName: string;
email?: string;
phone?: string;
accountId?: string;
};
if (!firstName || !lastName) {
throw new Error('First name and last name are required');
}
try {
const contactId = randomUUID();
await ctx.knex('contacts').insert({
id: contactId,
firstName,
lastName,
accountId,
ownerId: ctx.userId,
});
return {
success: true,
contactId,
contact: {
id: contactId,
firstName,
lastName,
accountId,
},
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
export const createPet: ToolHandler = async (ctx, args) => {
if (!ctx.knex) {
throw new Error('Knex connection required for createPet');
}
const { name, species, breed, age, ownerId } = args as {
name: string;
species: string;
breed?: string;
age?: number;
ownerId: string; // Contact ID
};
if (!name || !ownerId) {
throw new Error('Pet name and owner (contact) are required');
}
try {
const petId = randomUUID();
// Get the accountId from the contact
const contact = await ctx.knex('contacts').where('id', ownerId).first();
// Insert into dogs table
await ctx.knex('dogs').insert({
id: petId,
name,
ownerId,
accountId: contact?.accountId,
});
return {
success: true,
petId,
pet: { id: petId, name, ownerId, accountId: contact?.accountId },
};
} catch (error: any) {
return { success: false, error: error.message };
}
};
// Export all demo tools
export const demoTools = {
findAccount,
createAccount,
findContact,
createContact,
createPet,
};

View File

@@ -0,0 +1,89 @@
import { Knex } from 'knex';
import { AiToolConfig } from '../../models/ai-process.model';
export interface ToolContext {
tenantId: string;
userId: string;
knex?: Knex;
authScopes?: string[];
}
export type ToolHandler = (
ctx: ToolContext,
args: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
export interface ToolDefinition {
name: string;
description: string;
handler: ToolHandler;
inputSchema?: Record<string, unknown>;
}
const defaultTools: Record<string, ToolHandler> = {
findAccount: async () => ({ accountId: null, found: false }),
createAccount: async (_ctx, args) => ({ accountId: `acc_${Date.now()}`, args }),
findContact: async () => ({ contactId: null, found: false }),
createContact: async (_ctx, args) => ({ contactId: `con_${Date.now()}`, args }),
createPet: async (_ctx, args) => ({ petId: `pet_${Date.now()}`, args }),
};
const tenantAllowlist: Record<string, string[]> = {
default: Object.keys(defaultTools),
};
export class ToolRegistry {
private tools: Record<string, ToolHandler>;
private allowlist: Record<string, string[]>;
private dbAllowlistCache: Map<string, Set<string>> = new Map();
constructor(
tools: Record<string, ToolHandler> = defaultTools,
allowlist: Record<string, string[]> = tenantAllowlist,
) {
this.tools = tools;
this.allowlist = allowlist;
}
registerTool(name: string, handler: ToolHandler) {
this.tools[name] = handler;
}
async loadTenantAllowlist(tenantId: string, knex: Knex) {
const configs = await AiToolConfig.query(knex)
.where('enabled', true);
const allowed = new Set(configs.map((c) => c.toolName));
this.dbAllowlistCache.set(tenantId, allowed);
return allowed;
}
async isToolAllowed(tenantId: string, toolName: string, knex?: Knex) {
// Check database cache first
if (this.dbAllowlistCache.has(tenantId)) {
return this.dbAllowlistCache.get(tenantId)!.has(toolName);
}
// Load from database if knex provided
if (knex) {
const allowed = await this.loadTenantAllowlist(tenantId, knex);
return allowed.has(toolName);
}
// Fallback to static allowlist
const allowed = this.allowlist[tenantId] || this.allowlist.default || [];
return allowed.includes(toolName);
}
getTool(toolName: string): ToolHandler {
const tool = this.tools[toolName];
if (!tool) {
throw new Error(`Tool ${toolName} is not registered.`);
}
return tool;
}
getAllToolNames(): string[] {
return Object.keys(this.tools);
}
}

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { AppBuilderService } from './app-builder.service'; import { AppBuilderService } from './app-builder.service';
import { RuntimeAppController } from './runtime-app.controller'; import { RuntimeAppController } from './runtime-app.controller';
import { SetupAppController } from './setup-app.controller'; import { SetupAppController } from './setup-app.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({ @Module({
imports: [TenantModule],
providers: [AppBuilderService], providers: [AppBuilderService],
controllers: [RuntimeAppController, SetupAppController], controllers: [RuntimeAppController, SetupAppController],
exports: [AppBuilderService], exports: [AppBuilderService],

View File

@@ -1,44 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { App } from '../models/app.model';
import { AppPage } from '../models/app-page.model';
import { ObjectDefinition } from '../models/object-definition.model';
@Injectable() @Injectable()
export class AppBuilderService { export class AppBuilderService {
constructor(private prisma: PrismaService) {} constructor(private tenantDbService: TenantDatabaseService) {}
// Runtime endpoints // Runtime endpoints
async getApps(tenantId: string, userId: string) { async getApps(tenantId: string, userId: string) {
// For now, return all active apps for the tenant const knex = await this.tenantDbService.getTenantKnex(tenantId);
// For now, return all apps
// In production, you'd filter by user permissions // In production, you'd filter by user permissions
return this.prisma.app.findMany({ return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
where: {
tenantId,
isActive: true,
},
include: {
pages: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { label: 'asc' },
});
} }
async getApp(tenantId: string, slug: string, userId: string) { async getApp(tenantId: string, slug: string, userId: string) {
const app = await this.prisma.app.findUnique({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
where: { const app = await App.query(knex)
tenantId_slug: { .findOne({ slug })
tenantId, .withGraphFetched('pages');
slug,
},
},
include: {
pages: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!app) { if (!app) {
throw new NotFoundException(`App ${slug} not found`); throw new NotFoundException(`App ${slug} not found`);
@@ -53,23 +35,12 @@ export class AppBuilderService {
pageSlug: string, pageSlug: string,
userId: string, userId: string,
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getApp(tenantId, appSlug, userId); const app = await this.getApp(tenantId, appSlug, userId);
const page = await this.prisma.appPage.findFirst({ const page = await AppPage.query(knex).findOne({
where: { appId: app.id,
appId: app.id, slug: pageSlug,
slug: pageSlug,
isActive: true,
},
include: {
object: {
include: {
fields: {
where: { isActive: true },
},
},
},
},
}); });
if (!page) { if (!page) {
@@ -81,31 +52,15 @@ export class AppBuilderService {
// Setup endpoints // Setup endpoints
async getAllApps(tenantId: string) { async getAllApps(tenantId: string) {
return this.prisma.app.findMany({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
where: { tenantId }, return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
include: {
pages: {
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { label: 'asc' },
});
} }
async getAppForSetup(tenantId: string, slug: string) { async getAppForSetup(tenantId: string, slug: string) {
const app = await this.prisma.app.findUnique({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
where: { const app = await App.query(knex)
tenantId_slug: { .findOne({ slug })
tenantId, .withGraphFetched('pages');
slug,
},
},
include: {
pages: {
orderBy: { sortOrder: 'asc' },
},
},
});
if (!app) { if (!app) {
throw new NotFoundException(`App ${slug} not found`); throw new NotFoundException(`App ${slug} not found`);
@@ -120,14 +75,12 @@ export class AppBuilderService {
slug: string; slug: string;
label: string; label: string;
description?: string; description?: string;
icon?: string;
}, },
) { ) {
return this.prisma.app.create({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
data: { return App.query(knex).insert({
tenantId, ...data,
...data, displayOrder: 0,
},
}); });
} }
@@ -137,16 +90,12 @@ export class AppBuilderService {
data: { data: {
label?: string; label?: string;
description?: string; description?: string;
icon?: string;
isActive?: boolean;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, slug); const app = await this.getAppForSetup(tenantId, slug);
return this.prisma.app.update({ return App.query(knex).patchAndFetchById(app.id, data);
where: { id: app.id },
data,
});
} }
async createPage( async createPage(
@@ -157,37 +106,19 @@ export class AppBuilderService {
label: string; label: string;
type: string; type: string;
objectApiName?: string; objectApiName?: string;
config?: any;
sortOrder?: number; sortOrder?: number;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug); const app = await this.getAppForSetup(tenantId, appSlug);
// If objectApiName is provided, find the object return AppPage.query(knex).insert({
let objectId: string | undefined; appId: app.id,
if (data.objectApiName) { slug: data.slug,
const obj = await this.prisma.objectDefinition.findUnique({ label: data.label,
where: { type: data.type,
tenantId_apiName: { objectApiName: data.objectApiName,
tenantId, displayOrder: data.sortOrder || 0,
apiName: data.objectApiName,
},
},
});
objectId = obj?.id;
}
return this.prisma.appPage.create({
data: {
appId: app.id,
slug: data.slug,
label: data.label,
type: data.type,
objectApiName: data.objectApiName,
objectId,
config: data.config,
sortOrder: data.sortOrder || 0,
},
}); });
} }
@@ -199,44 +130,24 @@ export class AppBuilderService {
label?: string; label?: string;
type?: string; type?: string;
objectApiName?: string; objectApiName?: string;
config?: any;
sortOrder?: number; sortOrder?: number;
isActive?: boolean;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug); const app = await this.getAppForSetup(tenantId, appSlug);
const page = await this.prisma.appPage.findFirst({ const page = await AppPage.query(knex).findOne({
where: { appId: app.id,
appId: app.id, slug: pageSlug,
slug: pageSlug,
},
}); });
if (!page) { if (!page) {
throw new NotFoundException(`Page ${pageSlug} not found`); throw new NotFoundException(`Page ${pageSlug} not found`);
} }
// If objectApiName is provided, find the object return AppPage.query(knex).patchAndFetchById(page.id, {
let objectId: string | undefined; ...data,
if (data.objectApiName) { displayOrder: data.sortOrder,
const obj = await this.prisma.objectDefinition.findUnique({
where: {
tenantId_apiName: {
tenantId,
apiName: data.objectApiName,
},
},
});
objectId = obj?.id;
}
return this.prisma.appPage.update({
where: { id: page.id },
data: {
...data,
objectId,
},
}); });
} }
} }

View File

@@ -59,11 +59,6 @@ export class SetupAppController {
@Param('pageSlug') pageSlug: string, @Param('pageSlug') pageSlug: string,
@Body() data: any, @Body() data: any,
) { ) {
return this.appBuilderService.updatePage( return this.appBuilderService.updatePage(tenantId, appSlug, pageSlug, data);
tenantId,
appSlug,
pageSlug,
data,
);
} }
} }

View File

@@ -6,6 +6,10 @@ import { AuthModule } from './auth/auth.module';
import { RbacModule } from './rbac/rbac.module'; import { RbacModule } from './rbac/rbac.module';
import { ObjectModule } from './object/object.module'; import { ObjectModule } from './object/object.module';
import { AppBuilderModule } from './app-builder/app-builder.module'; import { AppBuilderModule } from './app-builder/app-builder.module';
import { PageLayoutModule } from './page-layout/page-layout.module';
import { VoiceModule } from './voice/voice.module';
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
import { AiProcessesModule } from './ai-processes/ai-processes.module';
@Module({ @Module({
imports: [ imports: [
@@ -18,6 +22,10 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
RbacModule, RbacModule,
ObjectModule, ObjectModule,
AppBuilderModule, AppBuilderModule,
PageLayoutModule,
VoiceModule,
AiAssistantModule,
AiProcessesModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -5,6 +5,7 @@ import {
UnauthorizedException, UnauthorizedException,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -40,17 +41,33 @@ class RegisterDto {
export class AuthController { export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
private isCentralSubdomain(subdomain: string): boolean {
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
return centralSubdomains.includes(subdomain);
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('login') @Post('login')
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) { async login(
if (!tenantId) { @TenantId() tenantId: string,
throw new UnauthorizedException('Tenant ID is required'); @Body() loginDto: LoginDto,
@Req() req: any,
) {
const subdomain = req.raw?.subdomain;
// If it's a central subdomain, tenantId is not required
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
} }
const user = await this.authService.validateUser( const user = await this.authService.validateUser(
tenantId, tenantId,
loginDto.email, loginDto.email,
loginDto.password, loginDto.password,
subdomain,
); );
if (!user) { if (!user) {
@@ -64,9 +81,15 @@ export class AuthController {
async register( async register(
@TenantId() tenantId: string, @TenantId() tenantId: string,
@Body() registerDto: RegisterDto, @Body() registerDto: RegisterDto,
@Req() req: any,
) { ) {
if (!tenantId) { const subdomain = req.raw?.subdomain;
throw new UnauthorizedException('Tenant ID is required');
// If it's a central subdomain, tenantId is not required
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
} }
const user = await this.authService.register( const user = await this.authService.register(
@@ -75,8 +98,17 @@ export class AuthController {
registerDto.password, registerDto.password,
registerDto.firstName, registerDto.firstName,
registerDto.lastName, registerDto.lastName,
subdomain,
); );
return user; return user;
} }
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout() {
// For stateless JWT, logout is handled on client-side
// This endpoint exists for consistency and potential future enhancements
return { message: 'Logged out successfully' };
}
} }

View File

@@ -5,10 +5,12 @@ import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { TenantModule } from '../tenant/tenant.module';
@Module({ @Module({
imports: [ imports: [
PassportModule, PassportModule,
TenantModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({

View File

@@ -1,48 +1,82 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private prisma: PrismaService, private tenantDbService: TenantDatabaseService,
private jwtService: JwtService, private jwtService: JwtService,
) {} ) {}
private isCentralSubdomain(subdomain: string): boolean {
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
return centralSubdomains.includes(subdomain);
}
async validateUser( async validateUser(
tenantId: string, tenantId: string,
email: string, email: string,
password: string, password: string,
subdomain?: string,
): Promise<any> { ): Promise<any> {
const user = await this.prisma.user.findUnique({
where: { // Check if this is a central subdomain
tenantId_email: { if (subdomain && this.isCentralSubdomain(subdomain)) {
tenantId, return this.validateCentralUser(email, password);
email, }
},
}, // Otherwise, validate as tenant user
include: { const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
tenant: true,
userRoles: { const user = await tenantDb('users')
include: { .where({ email })
role: { .first();
include: {
rolePermissions: { if (!user) {
include: { return null;
permission: true, }
},
}, if (await bcrypt.compare(password, user.password)) {
}, // Load user roles and permissions
}, const userRoles = await tenantDb('user_roles')
}, .where({ userId: user.id })
}, .join('roles', 'user_roles.roleId', 'roles.id')
}, .select('roles.*');
const { password: _, ...result } = user;
return {
...result,
tenantId,
userRoles,
};
}
return null;
}
private async validateCentralUser(
email: string,
password: string,
): Promise<any> {
const centralPrisma = getCentralPrisma();
const user = await centralPrisma.user.findUnique({
where: { email },
}); });
if (user && (await bcrypt.compare(password, user.password))) { if (!user) {
const { password, ...result } = user; return null;
return result; }
if (await bcrypt.compare(password, user.password)) {
const { password: _, ...result } = user;
return {
...result,
isCentralAdmin: true,
};
} }
return null; return null;
@@ -52,7 +86,6 @@ export class AuthService {
const payload = { const payload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
tenantId: user.tenantId,
}; };
return { return {
@@ -62,7 +95,6 @@ export class AuthService {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
tenantId: user.tenantId,
}, },
}; };
} }
@@ -73,16 +105,53 @@ export class AuthService {
password: string, password: string,
firstName?: string, firstName?: string,
lastName?: string, lastName?: string,
subdomain?: string,
) { ) {
// Check if this is a central subdomain
if (subdomain && this.isCentralSubdomain(subdomain)) {
return this.registerCentralUser(email, password, firstName, lastName);
}
// Otherwise, register as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({ const [userId] = await tenantDb('users').insert({
email,
password: hashedPassword,
firstName,
lastName,
isActive: true,
created_at: new Date(),
updated_at: new Date(),
});
const user = await tenantDb('users')
.where({ id: userId })
.first();
const { password: _, ...result } = user;
return result;
}
private async registerCentralUser(
email: string,
password: string,
firstName?: string,
lastName?: string,
) {
const centralPrisma = getCentralPrisma();
const hashedPassword = await bcrypt.hash(password, 10);
const user = await centralPrisma.user.create({
data: { data: {
tenantId,
email, email,
password: hashedPassword, password: hashedPassword,
firstName, firstName: firstName || null,
lastName, lastName: lastName || null,
isActive: true,
}, },
}); });

View File

@@ -3,13 +3,15 @@ import {
FastifyAdapter, FastifyAdapter,
NestFastifyApplication, NestFastifyApplication,
} from '@nestjs/platform-fastify'; } from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe, Logger } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { VoiceService } from './voice/voice.service';
import { AudioConverterService } from './voice/audio-converter.service';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
AppModule, AppModule,
new FastifyAdapter(), new FastifyAdapter({ logger: true }),
); );
// Global validation pipe // Global validation pipe
@@ -33,6 +35,145 @@ async function bootstrap() {
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0'); await app.listen(port, '0.0.0.0');
// After app is listening, register WebSocket handler
const fastifyInstance = app.getHttpAdapter().getInstance();
const logger = new Logger('MediaStreamWS');
const voiceService = app.get(VoiceService);
const audioConverter = app.get(AudioConverterService);
const WebSocketServer = require('ws').Server;
const wss = new WebSocketServer({ noServer: true });
// Handle WebSocket upgrades at the server level
const server = (fastifyInstance.server as any);
// Track active Media Streams connections: streamSid -> WebSocket
const mediaStreams: Map<string, any> = new Map();
server.on('upgrade', (request: any, socket: any, head: any) => {
if (request.url === '/api/voice/media-stream') {
logger.log('=== MEDIA STREAM WEBSOCKET UPGRADE REQUEST ===');
logger.log(`Path: ${request.url}`);
wss.handleUpgrade(request, socket, head, (ws: any) => {
logger.log('=== MEDIA STREAM WEBSOCKET UPGRADED SUCCESSFULLY ===');
handleMediaStreamSocket(ws);
});
}
});
async function handleMediaStreamSocket(ws: any) {
let streamSid: string | null = null;
let callSid: string | null = null;
let tenantDomain: string | null = null;
let mediaPacketCount = 0;
ws.on('message', async (message: Buffer) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.event) {
case 'connected':
logger.log('=== MEDIA STREAM EVENT: CONNECTED ===');
logger.log(`Protocol: ${msg.protocol}`);
logger.log(`Version: ${msg.version}`);
break;
case 'start':
streamSid = msg.streamSid;
callSid = msg.start.callSid;
tenantDomain = msg.start.customParameters?.tenantId || 'tenant1';
logger.log(`=== MEDIA STREAM EVENT: START ===`);
logger.log(`StreamSid: ${streamSid}`);
logger.log(`CallSid: ${callSid}`);
logger.log(`Tenant: ${tenantDomain}`);
logger.log(`MediaFormat: ${JSON.stringify(msg.start.mediaFormat)}`);
mediaStreams.set(streamSid, ws);
logger.log(`Stored WebSocket for streamSid: ${streamSid}. Total active streams: ${mediaStreams.size}`);
// Initialize OpenAI Realtime connection
logger.log(`Initializing OpenAI Realtime for call ${callSid}...`);
try {
await voiceService.initializeOpenAIRealtime({
callSid,
tenantId: tenantDomain,
userId: msg.start.customParameters?.userId || 'system',
});
logger.log(`✓ OpenAI Realtime initialized for call ${callSid}`);
} catch (error: any) {
logger.error(`Failed to initialize OpenAI: ${error.message}`);
}
break;
case 'media':
mediaPacketCount++;
// Only log every 500 packets to reduce noise
if (mediaPacketCount % 500 === 0) {
logger.log(`Received media packet #${mediaPacketCount} for StreamSid: ${streamSid}`);
}
if (!callSid || !tenantDomain) {
logger.warn('Received media before start event');
break;
}
try {
// Convert Twilio audio (μ-law 8kHz) to OpenAI format (PCM16 24kHz)
const twilioAudio = msg.media.payload;
const openaiAudio = audioConverter.twilioToOpenAI(twilioAudio);
// Send audio to OpenAI Realtime API
await voiceService.sendAudioToOpenAI(callSid, openaiAudio);
} catch (error: any) {
logger.error(`Error processing media: ${error.message}`);
}
break;
case 'stop':
logger.log(`=== MEDIA STREAM EVENT: STOP ===`);
logger.log(`StreamSid: ${streamSid}`);
logger.log(`Total media packets received: ${mediaPacketCount}`);
if (streamSid) {
mediaStreams.delete(streamSid);
logger.log(`Removed WebSocket for streamSid: ${streamSid}`);
}
// Clean up OpenAI connection
if (callSid) {
try {
logger.log(`Cleaning up OpenAI connection for call ${callSid}...`);
await voiceService.cleanupOpenAIConnection(callSid);
logger.log(`✓ OpenAI connection cleaned up`);
} catch (error: any) {
logger.error(`Failed to cleanup OpenAI: ${error.message}`);
}
}
break;
default:
logger.debug(`Unknown media stream event: ${msg.event}`);
}
} catch (error: any) {
logger.error(`Error processing media stream message: ${error.message}`);
}
});
ws.on('close', () => {
logger.log(`=== MEDIA STREAM WEBSOCKET CLOSED ===`);
if (streamSid) {
mediaStreams.delete(streamSid);
}
});
ws.on('error', (error: Error) => {
logger.error(`=== MEDIA STREAM WEBSOCKET ERROR ===`);
logger.error(`Error message: ${error.message}`);
});
}
console.log(`🚀 Application is running on: http://localhost:${port}/api`); console.log(`🚀 Application is running on: http://localhost:${port}/api`);
} }

View File

@@ -0,0 +1,306 @@
import { Injectable, Logger } from '@nestjs/common';
import { Knex } from 'knex';
export interface CustomMigrationRecord {
id: string;
tenantId: string;
name: string;
description: string;
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
sql: string;
status: 'pending' | 'executed' | 'failed';
executedAt?: Date;
error?: string;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class CustomMigrationService {
private readonly logger = new Logger(CustomMigrationService.name);
/**
* Generate SQL to create a table with standard fields
*/
generateCreateTableSQL(
tableName: string,
fields: {
apiName: string;
type: string;
isRequired?: boolean;
isUnique?: boolean;
defaultValue?: string;
}[] = [],
): string {
// Start with standard fields
const columns: string[] = [
'`id` VARCHAR(36) PRIMARY KEY',
'`ownerId` VARCHAR(36)',
'`name` VARCHAR(255)',
'`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
'`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
];
// Add custom fields
for (const field of fields) {
const column = this.fieldToColumn(field);
columns.push(column);
}
// Add foreign key and index for ownerId
columns.push('INDEX `idx_owner` (`ownerId`)');
return `CREATE TABLE IF NOT EXISTS \`${tableName}\` (
${columns.join(',\n ')}
)`;
}
/**
* Convert field definition to SQL column definition
*/
private fieldToColumn(field: {
apiName: string;
type: string;
isRequired?: boolean;
isUnique?: boolean;
defaultValue?: string;
}): string {
const columnName = field.apiName;
let columnDef = `\`${columnName}\``;
// Map field types to SQL types
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
columnDef += ' VARCHAR(255)';
break;
case 'LONG_TEXT':
columnDef += ' LONGTEXT';
break;
case 'NUMBER':
case 'DECIMAL':
columnDef += ' DECIMAL(18, 2)';
break;
case 'INTEGER':
columnDef += ' INT';
break;
case 'BOOLEAN':
columnDef += ' BOOLEAN DEFAULT FALSE';
break;
case 'DATE':
columnDef += ' DATE';
break;
case 'DATE_TIME':
columnDef += ' DATETIME';
break;
case 'EMAIL':
columnDef += ' VARCHAR(255)';
break;
case 'URL':
columnDef += ' VARCHAR(2048)';
break;
case 'PHONE':
columnDef += ' VARCHAR(20)';
break;
case 'CURRENCY':
columnDef += ' DECIMAL(18, 2)';
break;
case 'PERCENT':
columnDef += ' DECIMAL(5, 2)';
break;
case 'PICKLIST':
case 'MULTI_PICKLIST':
columnDef += ' VARCHAR(255)';
break;
case 'LOOKUP':
case 'BELONGS_TO':
columnDef += ' VARCHAR(36)';
break;
default:
columnDef += ' VARCHAR(255)';
}
// Add constraints
if (field.isRequired) {
columnDef += ' NOT NULL';
} else {
columnDef += ' NULL';
}
if (field.isUnique) {
columnDef += ' UNIQUE';
}
if (field.defaultValue !== undefined && field.defaultValue !== null) {
columnDef += ` DEFAULT '${field.defaultValue}'`;
}
return columnDef;
}
/**
* Create a custom migration record in the database
*/
async createMigrationRecord(
tenantKnex: Knex,
data: {
tenantId: string;
name: string;
description: string;
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
sql: string;
},
): Promise<CustomMigrationRecord> {
// Ensure custom_migrations table exists
await this.ensureMigrationsTable(tenantKnex);
const id = require('crypto').randomUUID();
const now = new Date();
await tenantKnex('custom_migrations').insert({
id,
tenantId: data.tenantId,
name: data.name,
description: data.description,
type: data.type,
sql: data.sql,
status: 'pending',
created_at: now,
updated_at: now,
});
return tenantKnex('custom_migrations').where({ id }).first();
}
/**
* Execute a pending migration and update its status
*/
async executeMigration(
tenantKnex: Knex,
migrationId: string,
): Promise<CustomMigrationRecord> {
try {
// Get the migration record
const migration = await tenantKnex('custom_migrations')
.where({ id: migrationId })
.first();
if (!migration) {
throw new Error(`Migration ${migrationId} not found`);
}
if (migration.status === 'executed') {
this.logger.log(`Migration ${migrationId} already executed`);
return migration;
}
// Execute the SQL
this.logger.log(`Executing migration: ${migration.name}`);
await tenantKnex.raw(migration.sql);
// Update status
const now = new Date();
await tenantKnex('custom_migrations')
.where({ id: migrationId })
.update({
status: 'executed',
executedAt: now,
updated_at: now,
});
this.logger.log(`Migration ${migration.name} executed successfully`);
return tenantKnex('custom_migrations').where({ id: migrationId }).first();
} catch (error) {
this.logger.error(`Failed to execute migration ${migrationId}:`, error);
// Update status with error
const now = new Date();
await tenantKnex('custom_migrations')
.where({ id: migrationId })
.update({
status: 'failed',
error: error.message,
updated_at: now,
});
throw error;
}
}
/**
* Create and execute a migration in one step
*/
async createAndExecuteMigration(
tenantKnex: Knex,
tenantId: string,
data: {
name: string;
description: string;
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
sql: string;
},
): Promise<CustomMigrationRecord> {
// Create the migration record
const migration = await this.createMigrationRecord(tenantKnex, {
tenantId,
...data,
});
// Execute it immediately
return this.executeMigration(tenantKnex, migration.id);
}
/**
* Ensure the custom_migrations table exists in the tenant database
*/
private async ensureMigrationsTable(tenantKnex: Knex): Promise<void> {
const hasTable = await tenantKnex.schema.hasTable('custom_migrations');
if (!hasTable) {
await tenantKnex.schema.createTable('custom_migrations', (table) => {
table.uuid('id').primary();
table.uuid('tenantId').notNullable();
table.string('name', 255).notNullable();
table.text('description');
table.enum('type', ['create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom']).notNullable();
table.text('sql').notNullable();
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
table.timestamp('executedAt').nullable();
table.text('error').nullable();
table.timestamps(true, true);
table.index(['tenantId']);
table.index(['status']);
table.index(['created_at']);
});
this.logger.log('Created custom_migrations table');
}
}
/**
* Get all migrations for a tenant
*/
async getMigrations(
tenantKnex: Knex,
tenantId: string,
filter?: {
status?: 'pending' | 'executed' | 'failed';
type?: string;
},
): Promise<CustomMigrationRecord[]> {
await this.ensureMigrationsTable(tenantKnex);
let query = tenantKnex('custom_migrations').where({ tenantId });
if (filter?.status) {
query = query.where({ status: filter.status });
}
if (filter?.type) {
query = query.where({ type: filter.type });
}
return query.orderBy('created_at', 'asc');
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CustomMigrationService } from './custom-migration.service';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
providers: [CustomMigrationService],
exports: [CustomMigrationService],
})
export class MigrationModule {}

View File

@@ -0,0 +1,23 @@
import { BaseModel } from './base.model';
export class Account extends BaseModel {
static tableName = 'accounts';
id!: string;
name!: string;
website?: string;
phone?: string;
industry?: string;
ownerId?: string;
static relationMappings = {
owner: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'user.model',
join: {
from: 'accounts.ownerId',
to: 'users.id',
},
},
};
}

View File

@@ -0,0 +1,63 @@
import { randomUUID } from 'crypto';
import { snakeCaseMappers } from 'objection';
import { BaseModel } from './base.model';
export class AiChatSession extends BaseModel {
static tableName = 'ai_chat_sessions';
static columnNameMappers = snakeCaseMappers();
id!: string;
userId!: string;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
messages: {
relation: BaseModel.HasManyRelation,
modelClass: AiChatMessage,
join: {
from: 'ai_chat_sessions.id',
to: 'ai_chat_messages.session_id',
},
},
};
}
}
export class AiChatMessage extends BaseModel {
static tableName = 'ai_chat_messages';
static columnNameMappers = snakeCaseMappers();
id!: string;
sessionId!: string;
role!: string;
content!: string;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
session: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiChatSession,
join: {
from: 'ai_chat_messages.session_id',
to: 'ai_chat_sessions.id',
},
},
};
}
}

View File

@@ -0,0 +1,164 @@
import { randomUUID } from 'crypto';
import { QueryContext, snakeCaseMappers } from 'objection';
import { BaseModel } from './base.model';
export class AiProcess extends BaseModel {
static tableName = 'ai_processes';
static columnNameMappers = snakeCaseMappers();
id!: string;
name!: string;
description?: string;
latestVersion!: number;
createdBy!: string;
createdAt!: Date;
updatedAt!: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
super.$beforeInsert(queryContext);
}
static get relationMappings() {
return {
versions: {
relation: BaseModel.HasManyRelation,
modelClass: AiProcessVersion,
join: {
from: 'ai_processes.id',
to: 'ai_process_versions.process_id',
},
},
runs: {
relation: BaseModel.HasManyRelation,
modelClass: AiProcessRun,
join: {
from: 'ai_processes.id',
to: 'ai_process_runs.process_id',
},
},
};
}
}
export class AiProcessVersion extends BaseModel {
static tableName = 'ai_process_versions';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['graphJson', 'compiledJson'];
id!: string;
processId!: string;
version!: number;
graphJson!: Record<string, unknown>;
compiledJson!: Record<string, unknown>;
createdBy!: string;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
process: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiProcess,
join: {
from: 'ai_process_versions.process_id',
to: 'ai_processes.id',
},
},
};
}
}
export class AiProcessRun extends BaseModel {
static tableName = 'ai_process_runs';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['inputJson', 'outputJson', 'errorJson', 'stateJson'];
id!: string;
processId!: string;
version!: number;
status!: string;
inputJson!: Record<string, unknown>;
outputJson?: Record<string, unknown> | null;
errorJson?: Record<string, unknown> | null;
stateJson?: Record<string, unknown>;
currentNodeId?: string | null;
startedAt?: Date;
endedAt?: Date | null;
$beforeInsert() {
this.id = this.id || randomUUID();
this.startedAt = this.startedAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
process: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiProcess,
join: {
from: 'ai_process_runs.process_id',
to: 'ai_processes.id',
},
},
};
}
}
export class AiAuditEvent extends BaseModel {
static tableName = 'ai_audit_events';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['payloadJson'];
id!: string;
runId!: string;
eventType!: string;
payloadJson!: Record<string, unknown>;
createdAt!: Date;
$beforeInsert() {
this.id = this.id || randomUUID();
this.createdAt = this.createdAt || new Date();
}
$beforeUpdate() {}
static get relationMappings() {
return {
run: {
relation: BaseModel.BelongsToOneRelation,
modelClass: AiProcessRun,
join: {
from: 'ai_audit_events.run_id',
to: 'ai_process_runs.id',
},
},
};
}
}
export class AiToolConfig extends BaseModel {
static tableName = 'ai_tool_configs';
static columnNameMappers = snakeCaseMappers();
static jsonAttributes = ['configJson'];
id!: string;
toolName!: string;
enabled!: boolean;
configJson?: Record<string, unknown>;
createdAt!: Date;
updatedAt!: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
super.$beforeInsert(queryContext);
}
}

View File

@@ -0,0 +1,25 @@
import { BaseModel } from './base.model';
import { App } from './app.model';
export class AppPage extends BaseModel {
static tableName = 'app_pages';
id!: string;
appId!: string;
slug!: string;
label!: string;
type!: string;
objectApiName?: string;
displayOrder!: number;
static relationMappings = {
app: {
relation: BaseModel.BelongsToOneRelation,
modelClass: App,
join: {
from: 'app_pages.appId',
to: 'apps.id',
},
},
};
}

View File

@@ -0,0 +1,23 @@
import { BaseModel } from './base.model';
import { AppPage } from './app-page.model';
export class App extends BaseModel {
static tableName = 'apps';
id!: string;
slug!: string;
label!: string;
description?: string;
displayOrder!: number;
static relationMappings = {
pages: {
relation: BaseModel.HasManyRelation,
modelClass: AppPage,
join: {
from: 'apps.id',
to: 'app_pages.appId',
},
},
};
}

View File

@@ -0,0 +1,49 @@
import { Model, ModelOptions, QueryContext } from 'objection';
export class BaseModel extends Model {
/**
* Use a minimal column mapper: keep property names as-is, but handle
* timestamp fields that are stored as created_at/updated_at in the DB.
*/
static columnNameMappers = {
parse(dbRow: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(dbRow || {})) {
if (key === 'created_at') {
mapped.createdAt = value;
} else if (key === 'updated_at') {
mapped.updatedAt = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
format(model: Record<string, any>) {
const mapped: Record<string, any> = {};
for (const [key, value] of Object.entries(model || {})) {
if (key === 'createdAt') {
mapped.created_at = value;
} else if (key === 'updatedAt') {
mapped.updated_at = value;
} else {
mapped[key] = value;
}
}
return mapped;
},
};
id: string;
createdAt: Date;
updatedAt: Date;
$beforeInsert(queryContext: QueryContext) {
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,114 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { randomUUID } from 'crypto';
/**
* Central database models using Objection.js
* These models work with the central database (not tenant databases)
*/
export class CentralTenant extends Model {
static tableName = 'tenants';
id: string;
name: string;
slug: string;
dbHost: string;
dbPort: number;
dbName: string;
dbUsername: string;
dbPassword: string;
status: string;
createdAt: Date;
updatedAt: Date;
// Relations
domains?: CentralDomain[];
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
// Auto-generate slug from name if not provided
if (!this.slug && this.name) {
this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
static get relationMappings() {
return {
domains: {
relation: Model.HasManyRelation,
modelClass: CentralDomain,
join: {
from: 'tenants.id',
to: 'domains.tenantId',
},
},
};
}
}
export class CentralDomain extends Model {
static tableName = 'domains';
id: string;
domain: string;
tenantId: string;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
// Relations
tenant?: CentralTenant;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
static get relationMappings() {
return {
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: CentralTenant,
join: {
from: 'domains.tenantId',
to: 'tenants.id',
},
},
};
}
}
export class CentralUser extends Model {
static tableName = 'users';
id: string;
email: string;
password: string;
firstName: string | null;
lastName: string | null;
role: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,33 @@
import { BaseModel } from './base.model';
export class ContactDetail extends BaseModel {
static tableName = 'contact_details';
id!: string;
relatedObjectType!: 'Account' | 'Contact';
relatedObjectId!: string;
detailType!: string;
label?: string;
value!: string;
isPrimary!: boolean;
// Provide optional relations for each supported parent type.
static relationMappings = {
account: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'account.model',
join: {
from: 'contact_details.relatedObjectId',
to: 'accounts.id',
},
},
contact: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'contact.model',
join: {
from: 'contact_details.relatedObjectId',
to: 'contacts.id',
},
},
};
}

View File

@@ -0,0 +1,30 @@
import { BaseModel } from './base.model';
export class Contact extends BaseModel {
static tableName = 'contacts';
id!: string;
firstName!: string;
lastName!: string;
accountId!: string;
ownerId?: string;
static relationMappings = {
account: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'account.model',
join: {
from: 'contacts.accountId',
to: 'accounts.id',
},
},
owner: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'user.model',
join: {
from: 'contacts.ownerId',
to: 'users.id',
},
},
};
}

View File

@@ -0,0 +1,88 @@
import { BaseModel } from './base.model';
export interface FieldOption {
label: string;
value: string | number | boolean;
}
export interface ValidationRule {
type: 'required' | 'min' | 'max' | 'email' | 'url' | 'pattern' | 'custom';
value?: any;
message?: string;
}
export interface UIMetadata {
// Display properties
placeholder?: string;
helpText?: string;
// View visibility
showOnList?: boolean;
showOnDetail?: boolean;
showOnEdit?: boolean;
sortable?: boolean;
// Field type specific options
options?: FieldOption[]; // For select, multi-select
rows?: number; // For textarea
min?: number; // For number, date
max?: number; // For number, date
step?: number; // For number
accept?: string; // For file/image
relationDisplayField?: string; // Which field to display for relations
relationObjects?: string[]; // For polymorphic relations
relationTypeField?: string; // Field API name storing the selected relation type
// Formatting
format?: string; // Date format, number format, etc.
prefix?: string; // Currency symbol, etc.
suffix?: string;
// Validation
validationRules?: ValidationRule[];
// Advanced
dependsOn?: string[]; // Field dependencies
computedValue?: string; // Formula for computed fields
}
export class FieldDefinition extends BaseModel {
static tableName = 'field_definitions';
id!: string;
objectDefinitionId!: string;
apiName!: string;
label!: string;
type!: string;
length?: number;
precision?: number;
scale?: number;
referenceObject?: string;
defaultValue?: string;
description?: string;
isRequired!: boolean;
isUnique!: boolean;
isSystem!: boolean;
isCustom!: boolean;
displayOrder!: number;
uiMetadata?: UIMetadata;
static relationMappings = {
objectDefinition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'object-definition.model',
join: {
from: 'field_definitions.objectDefinitionId',
to: 'object_definitions.id',
},
},
rolePermissions: {
relation: BaseModel.HasManyRelation,
modelClass: () => require('./role-field-permission.model').RoleFieldPermission,
join: {
from: 'field_definitions.id',
to: 'role_field_permissions.fieldDefinitionId',
},
},
};
}

View File

@@ -0,0 +1,59 @@
import { BaseModel } from './base.model';
export class ObjectDefinition extends BaseModel {
static tableName = 'object_definitions';
id: string;
apiName: string;
label: string;
pluralLabel?: string;
description?: string;
isSystem: boolean;
isCustom: boolean;
orgWideDefault: 'private' | 'public_read' | 'public_read_write';
createdAt: Date;
updatedAt: Date;
fields?: any[];
rolePermissions?: any[];
static get jsonSchema() {
return {
type: 'object',
required: ['apiName', 'label'],
properties: {
id: { type: 'string' },
apiName: { type: 'string' },
label: { type: 'string' },
pluralLabel: { type: 'string' },
description: { type: 'string' },
isSystem: { type: 'boolean' },
isCustom: { type: 'boolean' },
orgWideDefault: { type: 'string', enum: ['private', 'public_read', 'public_read_write'] },
},
};
}
static get relationMappings() {
const { FieldDefinition } = require('./field-definition.model');
const { RoleObjectPermission } = require('./role-object-permission.model');
return {
fields: {
relation: BaseModel.HasManyRelation,
modelClass: FieldDefinition,
join: {
from: 'object_definitions.id',
to: 'field_definitions.objectDefinitionId',
},
},
rolePermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RoleObjectPermission,
join: {
from: 'object_definitions.id',
to: 'role_object_permissions.objectDefinitionId',
},
},
};
}
}

View File

@@ -0,0 +1,25 @@
import { BaseModel } from './base.model';
export class Permission extends BaseModel {
static tableName = 'permissions';
id!: string;
name!: string;
guardName!: string;
description?: string;
static relationMappings = {
roles: {
relation: BaseModel.ManyToManyRelation,
modelClass: 'role.model',
join: {
from: 'permissions.id',
through: {
from: 'role_permissions.permissionId',
to: 'role_permissions.roleId',
},
to: 'roles.id',
},
},
};
}

View File

@@ -0,0 +1,113 @@
import { BaseModel } from './base.model';
export interface RecordShareAccessLevel {
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
}
export class RecordShare extends BaseModel {
static tableName = 'record_shares';
// Don't use snake_case mapping since DB columns are already camelCase
static get columnNameMappers() {
return {
parse(obj: any) {
return obj;
},
format(obj: any) {
return obj;
},
};
}
// Don't auto-set timestamps - let DB defaults handle them
$beforeInsert() {
// Don't call super - skip BaseModel's timestamp logic
}
$beforeUpdate() {
// Don't call super - skip BaseModel's timestamp logic
}
id!: string;
objectDefinitionId!: string;
recordId!: string;
granteeUserId!: string;
grantedByUserId!: string;
accessLevel!: RecordShareAccessLevel;
expiresAt?: Date;
revokedAt?: Date;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['objectDefinitionId', 'recordId', 'granteeUserId', 'grantedByUserId', 'accessLevel'],
properties: {
id: { type: 'string' },
objectDefinitionId: { type: 'string' },
recordId: { type: 'string' },
granteeUserId: { type: 'string' },
grantedByUserId: { type: 'string' },
accessLevel: {
type: 'object',
properties: {
canRead: { type: 'boolean' },
canEdit: { type: 'boolean' },
canDelete: { type: 'boolean' },
},
},
expiresAt: {
anyOf: [
{ type: 'string', format: 'date-time' },
{ type: 'null' },
{ type: 'object' } // Allow Date objects
]
},
revokedAt: {
anyOf: [
{ type: 'string', format: 'date-time' },
{ type: 'null' },
{ type: 'object' } // Allow Date objects
]
},
createdAt: { type: ['string', 'object'], format: 'date-time' },
updatedAt: { type: ['string', 'object'], format: 'date-time' },
},
};
}
static get relationMappings() {
const { ObjectDefinition } = require('./object-definition.model');
const { User } = require('./user.model');
return {
objectDefinition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: ObjectDefinition,
join: {
from: 'record_shares.objectDefinitionId',
to: 'object_definitions.id',
},
},
granteeUser: {
relation: BaseModel.BelongsToOneRelation,
modelClass: User,
join: {
from: 'record_shares.granteeUserId',
to: 'users.id',
},
},
grantedByUser: {
relation: BaseModel.BelongsToOneRelation,
modelClass: User,
join: {
from: 'record_shares.grantedByUserId',
to: 'users.id',
},
},
};
}
}

View File

@@ -0,0 +1,51 @@
import { BaseModel } from './base.model';
export class RoleFieldPermission extends BaseModel {
static tableName = 'role_field_permissions';
id!: string;
roleId!: string;
fieldDefinitionId!: string;
canRead!: boolean;
canEdit!: boolean;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['roleId', 'fieldDefinitionId'],
properties: {
id: { type: 'string' },
roleId: { type: 'string' },
fieldDefinitionId: { type: 'string' },
canRead: { type: 'boolean' },
canEdit: { type: 'boolean' },
},
};
}
static get relationMappings() {
const { Role } = require('./role.model');
const { FieldDefinition } = require('./field-definition.model');
return {
role: {
relation: BaseModel.BelongsToOneRelation,
modelClass: Role,
join: {
from: 'role_field_permissions.roleId',
to: 'roles.id',
},
},
fieldDefinition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: FieldDefinition,
join: {
from: 'role_field_permissions.fieldDefinitionId',
to: 'field_definitions.id',
},
},
};
}
}

View File

@@ -0,0 +1,59 @@
import { BaseModel } from './base.model';
export class RoleObjectPermission extends BaseModel {
static tableName = 'role_object_permissions';
id!: string;
roleId!: string;
objectDefinitionId!: string;
canCreate!: boolean;
canRead!: boolean;
canEdit!: boolean;
canDelete!: boolean;
canViewAll!: boolean;
canModifyAll!: boolean;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['roleId', 'objectDefinitionId'],
properties: {
id: { type: 'string' },
roleId: { type: 'string' },
objectDefinitionId: { type: 'string' },
canCreate: { type: 'boolean' },
canRead: { type: 'boolean' },
canEdit: { type: 'boolean' },
canDelete: { type: 'boolean' },
canViewAll: { type: 'boolean' },
canModifyAll: { type: 'boolean' },
},
};
}
static get relationMappings() {
const { Role } = require('./role.model');
const { ObjectDefinition } = require('./object-definition.model');
return {
role: {
relation: BaseModel.BelongsToOneRelation,
modelClass: Role,
join: {
from: 'role_object_permissions.roleId',
to: 'roles.id',
},
},
objectDefinition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: ObjectDefinition,
join: {
from: 'role_object_permissions.objectDefinitionId',
to: 'object_definitions.id',
},
},
};
}
}

View File

@@ -0,0 +1,28 @@
import { BaseModel } from './base.model';
export class RolePermission extends BaseModel {
static tableName = 'role_permissions';
id!: string;
roleId!: string;
permissionId!: string;
static relationMappings = {
role: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'role.model',
join: {
from: 'role_permissions.roleId',
to: 'roles.id',
},
},
permission: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'permission.model',
join: {
from: 'role_permissions.permissionId',
to: 'permissions.id',
},
},
};
}

View File

@@ -0,0 +1,84 @@
import { BaseModel } from './base.model';
export class Role extends BaseModel {
static tableName = 'roles';
id: string;
name: string;
guardName: string;
description?: string;
createdAt: Date;
updatedAt: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
guardName: { type: 'string' },
description: { type: 'string' },
},
};
}
static get relationMappings() {
const { RolePermission } = require('./role-permission.model');
const { Permission } = require('./permission.model');
const { User } = require('./user.model');
const { RoleObjectPermission } = require('./role-object-permission.model');
const { RoleFieldPermission } = require('./role-field-permission.model');
return {
rolePermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RolePermission,
join: {
from: 'roles.id',
to: 'role_permissions.roleId',
},
},
permissions: {
relation: BaseModel.ManyToManyRelation,
modelClass: Permission,
join: {
from: 'roles.id',
through: {
from: 'role_permissions.roleId',
to: 'role_permissions.permissionId',
},
to: 'permissions.id',
},
},
users: {
relation: BaseModel.ManyToManyRelation,
modelClass: User,
join: {
from: 'roles.id',
through: {
from: 'user_roles.roleId',
to: 'user_roles.userId',
},
to: 'users.id',
},
},
objectPermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RoleObjectPermission,
join: {
from: 'roles.id',
to: 'role_object_permissions.roleId',
},
},
fieldPermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RoleFieldPermission,
join: {
from: 'roles.id',
to: 'role_field_permissions.roleId',
},
},
};
}
}

View File

@@ -0,0 +1,28 @@
import { BaseModel } from './base.model';
export class UserRole extends BaseModel {
static tableName = 'user_roles';
id!: string;
userId!: string;
roleId!: string;
static relationMappings = {
user: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'user.model',
join: {
from: 'user_roles.userId',
to: 'users.id',
},
},
role: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'role.model',
join: {
from: 'user_roles.roleId',
to: 'roles.id',
},
},
};
}

View File

@@ -0,0 +1,57 @@
import { BaseModel } from './base.model';
export class User extends BaseModel {
static tableName = 'users';
id: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['email', 'password'],
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
password: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
isActive: { type: 'boolean' },
},
};
}
static get relationMappings() {
const { UserRole } = require('./user-role.model');
const { Role } = require('./role.model');
return {
userRoles: {
relation: BaseModel.HasManyRelation,
modelClass: UserRole,
join: {
from: 'users.id',
to: 'user_roles.userId',
},
},
roles: {
relation: BaseModel.ManyToManyRelation,
modelClass: Role,
join: {
from: 'users.id',
through: {
from: 'user_roles.userId',
to: 'user_roles.roleId',
},
to: 'roles.id',
},
},
};
}
}

Some files were not shown because too many files have changed in this diff Show More