Compare commits
13 Commits
multitenan
...
de65aa4025
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de65aa4025 | ||
|
|
ded413b99b | ||
|
|
20fc90a3fb | ||
|
|
51c82d3d95 | ||
|
|
a4577ddcf3 | ||
|
|
5f3fcef1ec | ||
|
|
16907aadf8 | ||
|
|
838a010fb2 | ||
|
|
be6e34914e | ||
|
|
db9848cce7 | ||
|
|
cdc202454f | ||
|
|
f4067c56b4 | ||
|
|
0fe56c0e03 |
9
.env.api
9
.env.api
@@ -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"
|
||||||
|
|||||||
2
.env.web
2
.env.web
@@ -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
|
||||||
|
|||||||
324
AI_PROCESS_BUILDER_README.md
Normal file
324
AI_PROCESS_BUILDER_README.md
Normal 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
83
DEBUG_INCOMING_CALL.md
Normal 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
173
SOFTPHONE_AI_ASSISTANT.md
Normal 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
23
backend/.env.example
Normal 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"
|
||||||
91
backend/MIGRATION_QUICK_REFERENCE.txt
Normal file
91
backend/MIGRATION_QUICK_REFERENCE.txt
Normal 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 ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════════╝
|
||||||
115
backend/insert-demo-process.sql
Normal file
115
backend/insert-demo-process.sql
Normal 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
19
backend/knexfile.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
35
backend/migrations/tenant/20250126000003_create_apps.js
Normal file
35
backend/migrations/tenant/20250126000003_create_apps.js
Normal 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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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
1662
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
238
backend/prisma/migrations/20251129033827_init/migration.sql
Normal file
238
backend/prisma/migrations/20251129033827_init/migration.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `tenants` ADD COLUMN `integrationsConfig` JSON NULL;
|
||||||
56
backend/prisma/schema-central.prisma
Normal file
56
backend/prisma/schema-central.prisma
Normal 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")
|
||||||
|
}
|
||||||
@@ -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
239
backend/scripts/README.md
Normal 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
|
||||||
|
```
|
||||||
181
backend/scripts/check-migration-status.ts
Normal file
181
backend/scripts/check-migration-status.ts
Normal 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);
|
||||||
|
});
|
||||||
50
backend/scripts/create-admin-user.ts
Normal file
50
backend/scripts/create-admin-user.ts
Normal 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();
|
||||||
138
backend/scripts/create-tenant-user.ts
Normal file
138
backend/scripts/create-tenant-user.ts
Normal 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();
|
||||||
169
backend/scripts/migrate-all-tenants.ts
Normal file
169
backend/scripts/migrate-all-tenants.ts
Normal 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);
|
||||||
|
});
|
||||||
134
backend/scripts/migrate-tenant.ts
Normal file
134
backend/scripts/migrate-tenant.ts
Normal 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);
|
||||||
|
});
|
||||||
181
backend/scripts/seed-default-roles.ts
Normal file
181
backend/scripts/seed-default-roles.ts
Normal 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);
|
||||||
|
});
|
||||||
332
backend/scripts/seed-demo-process.ts
Normal file
332
backend/scripts/seed-demo-process.ts
Normal 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);
|
||||||
72
backend/scripts/update-name-field.ts
Normal file
72
backend/scripts/update-name-field.ts
Normal 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();
|
||||||
147
backend/seeds/example_account_fields_with_ui_metadata.js
Normal file
147
backend/seeds/example_account_fields_with_ui_metadata.js
Normal 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');
|
||||||
|
};
|
||||||
349
backend/seeds/example_contact_fields_with_ui_metadata.js
Normal file
349
backend/seeds/example_contact_fields_with_ui_metadata.js
Normal 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!');
|
||||||
|
};
|
||||||
41
backend/src/ai-assistant/ai-assistant.controller.ts
Normal file
41
backend/src/ai-assistant/ai-assistant.controller.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/ai-assistant/ai-assistant.module.ts
Normal file
15
backend/src/ai-assistant/ai-assistant.module.ts
Normal 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 {}
|
||||||
1236
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
1236
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal file
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal 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;
|
||||||
|
}
|
||||||
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal file
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal file
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
191
backend/src/ai-processes/ai-processes.compiler.ts
Normal file
191
backend/src/ai-processes/ai-processes.compiler.ts
Normal 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));
|
||||||
|
};
|
||||||
144
backend/src/ai-processes/ai-processes.controller.ts
Normal file
144
backend/src/ai-processes/ai-processes.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/ai-processes/ai-processes.module.ts
Normal file
19
backend/src/ai-processes/ai-processes.module.ts
Normal 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 {}
|
||||||
212
backend/src/ai-processes/ai-processes.orchestrator.service.ts
Normal file
212
backend/src/ai-processes/ai-processes.orchestrator.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
222
backend/src/ai-processes/ai-processes.runner.ts
Normal file
222
backend/src/ai-processes/ai-processes.runner.ts
Normal 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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
79
backend/src/ai-processes/ai-processes.schemas.ts
Normal file
79
backend/src/ai-processes/ai-processes.schemas.ts
Normal 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;
|
||||||
|
};
|
||||||
319
backend/src/ai-processes/ai-processes.service.ts
Normal file
319
backend/src/ai-processes/ai-processes.service.ts
Normal 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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/ai-processes/ai-processes.stream.service.ts
Normal file
33
backend/src/ai-processes/ai-processes.stream.service.ts
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/src/ai-processes/ai-processes.types.ts
Normal file
125
backend/src/ai-processes/ai-processes.types.ts
Normal 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;
|
||||||
|
}
|
||||||
202
backend/src/ai-processes/deep-agent.orchestrator.ts
Normal file
202
backend/src/ai-processes/deep-agent.orchestrator.ts
Normal 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.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/src/ai-processes/demo-process.ts
Normal file
173
backend/src/ai-processes/demo-process.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
28
backend/src/ai-processes/dto/ai-chat.dto.ts
Normal file
28
backend/src/ai-processes/dto/ai-chat.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
24
backend/src/ai-processes/dto/ai-process.dto.ts
Normal file
24
backend/src/ai-processes/dto/ai-process.dto.ts
Normal 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>[];
|
||||||
|
}
|
||||||
19
backend/src/ai-processes/dto/ai-run.dto.ts
Normal file
19
backend/src/ai-processes/dto/ai-run.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
226
backend/src/ai-processes/tools/demo-tools.ts
Normal file
226
backend/src/ai-processes/tools/demo-tools.ts
Normal 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,
|
||||||
|
};
|
||||||
89
backend/src/ai-processes/tools/tool-registry.ts
Normal file
89
backend/src/ai-processes/tools/tool-registry.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
306
backend/src/migration/custom-migration.service.ts
Normal file
306
backend/src/migration/custom-migration.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/migration/migration.module.ts
Normal file
10
backend/src/migration/migration.module.ts
Normal 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 {}
|
||||||
23
backend/src/models/account.model.ts
Normal file
23
backend/src/models/account.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
63
backend/src/models/ai-chat.model.ts
Normal file
63
backend/src/models/ai-chat.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
164
backend/src/models/ai-process.model.ts
Normal file
164
backend/src/models/ai-process.model.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/models/app-page.model.ts
Normal file
25
backend/src/models/app-page.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
23
backend/src/models/app.model.ts
Normal file
23
backend/src/models/app.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
49
backend/src/models/base.model.ts
Normal file
49
backend/src/models/base.model.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
114
backend/src/models/central.model.ts
Normal file
114
backend/src/models/central.model.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/models/contact-detail.model.ts
Normal file
33
backend/src/models/contact-detail.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
30
backend/src/models/contact.model.ts
Normal file
30
backend/src/models/contact.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
88
backend/src/models/field-definition.model.ts
Normal file
88
backend/src/models/field-definition.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
59
backend/src/models/object-definition.model.ts
Normal file
59
backend/src/models/object-definition.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/models/permission.model.ts
Normal file
25
backend/src/models/permission.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
113
backend/src/models/record-share.model.ts
Normal file
113
backend/src/models/record-share.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/models/role-field-permission.model.ts
Normal file
51
backend/src/models/role-field-permission.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/src/models/role-object-permission.model.ts
Normal file
59
backend/src/models/role-object-permission.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/models/role-permission.model.ts
Normal file
28
backend/src/models/role-permission.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
84
backend/src/models/role.model.ts
Normal file
84
backend/src/models/role.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/models/user-role.model.ts
Normal file
28
backend/src/models/user-role.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
57
backend/src/models/user.model.ts
Normal file
57
backend/src/models/user.model.ts
Normal 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
Reference in New Issue
Block a user