9 Commits

Author SHA1 Message Date
Francisco Gaona
4520f94b69 WIP - using objection base model to handle objects operations 2025-12-24 20:18:43 +01:00
Francisco Gaona
e4f1ba96ad WIP - custom migrations when object is created 2025-12-24 19:54:13 +01:00
Francisco Gaona
52c0849de2 WIP - manage tenant users from central 2025-12-24 12:17:22 +01:00
Francisco Gaona
b9fa3bd008 WIP - improve login to tenants by domains 2025-12-24 11:42:44 +01:00
Francisco Gaona
2bc672e4c5 WIP - some fixes 2025-12-24 10:54:19 +01:00
Francisco Gaona
962c84e6d2 WIP - fix lookup field 2025-12-24 00:05:15 +01:00
Francisco Gaona
fc1bec4de7 WIP - related lists and look up field 2025-12-23 23:59:04 +01:00
Francisco Gaona
0275b96014 WIP - central operations 2025-12-23 23:38:45 +01:00
Francisco Gaona
e4f3bad971 WIp - fix login into central 2025-12-23 22:16:58 +01:00
148 changed files with 673 additions and 19478 deletions

View File

@@ -5,11 +5,6 @@ DATABASE_URL="mysql://platform:platform@db:3306/platform"
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
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_SECRET="devsecret"
TENANCY_STRATEGY="single-db"

View File

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

View File

@@ -1,83 +0,0 @@
# 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)

View File

@@ -1,173 +0,0 @@
# 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

View File

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

View File

@@ -1,95 +0,0 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// Check if layout_type column already exists (in case of partial migration)
const hasLayoutType = await knex.schema.hasColumn('page_layouts', 'layout_type');
// Check if the old index exists
const [indexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_is_default_index'`);
const hasOldIndex = indexes.length > 0;
// Check if foreign key exists
const [fks] = await knex.raw(`
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'page_layouts'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign'
`);
const hasForeignKey = fks.length > 0;
if (hasOldIndex) {
// First, drop the foreign key constraint that depends on the index (if it exists)
if (hasForeignKey) {
await knex.schema.alterTable('page_layouts', (table) => {
table.dropForeign(['object_id']);
});
}
// Now we can safely drop the old index
await knex.schema.alterTable('page_layouts', (table) => {
table.dropIndex(['object_id', 'is_default']);
});
}
// Add layout_type column if it doesn't exist
if (!hasLayoutType) {
await knex.schema.alterTable('page_layouts', (table) => {
// Add layout_type column to distinguish between detail/edit layouts and list view layouts
// Default to 'detail' for existing layouts
table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name');
});
}
// Check if new index exists
const [newIndexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_layout_type_is_default_index'`);
const hasNewIndex = newIndexes.length > 0;
if (!hasNewIndex) {
// Create new index including layout_type
await knex.schema.alterTable('page_layouts', (table) => {
table.index(['object_id', 'layout_type', 'is_default']);
});
}
// Re-check if foreign key exists (may have been dropped above or in previous attempt)
const [fksAfter] = await knex.raw(`
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'page_layouts'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign'
`);
if (fksAfter.length === 0) {
// Re-add the foreign key constraint
await knex.schema.alterTable('page_layouts', (table) => {
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
});
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// Drop the foreign key first
await knex.schema.alterTable('page_layouts', (table) => {
table.dropForeign(['object_id']);
});
// Drop the new index and column, restore old index
await knex.schema.alterTable('page_layouts', (table) => {
table.dropIndex(['object_id', 'layout_type', 'is_default']);
table.dropColumn('layout_type');
table.index(['object_id', 'is_default']);
});
// Re-add the foreign key constraint
await knex.schema.alterTable('page_layouts', (table) => {
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
});
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
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');
});
};

1290
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,11 +26,6 @@
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
},
"dependencies": {
"@casl/ability": "^6.7.5",
"@fastify/websocket": "^10.0.1",
"@langchain/core": "^1.1.15",
"@langchain/langgraph": "^1.0.15",
"@langchain/openai": "^1.2.1",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
@@ -38,28 +33,19 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.0",
"@nestjs/platform-socket.io": "^10.4.20",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.20",
"@prisma/client": "^5.8.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"deepagents": "^1.5.0",
"ioredis": "^5.3.2",
"knex": "^3.1.0",
"langchain": "^1.2.10",
"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",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"twilio": "^5.11.1",
"ws": "^8.18.3"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",

View File

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

View File

@@ -24,18 +24,17 @@ model User {
}
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
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
status String @default("active") // active, suspended, deleted
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
domains Domain[]

View File

@@ -125,7 +125,6 @@ model FieldDefinition {
isSystem Boolean @default(false)
isCustom Boolean @default(true)
displayOrder Int @default(0)
uiMetadata Json? @map("ui_metadata")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -145,42 +144,12 @@ model Account {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id])
contacts Contact[]
owner User @relation(fields: [ownerId], references: [id])
@@index([ownerId])
@@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")
}
// Application Builder
model App {
id String @id @default(uuid())

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
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],
})
export class AiAssistantModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +0,0 @@
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' | 'plan_complete' | 'plan_pending';
missingFields?: string[];
record?: any;
records?: any[]; // Multiple records when plan execution completes
plan?: RecordCreationPlan;
}
// ============================================
// Entity Discovery Types
// ============================================
export interface EntityFieldInfo {
apiName: string;
label: string;
type: string;
isRequired: boolean;
isSystem: boolean;
referenceObject?: string; // For LOOKUP fields, the target entity
description?: string;
}
export interface EntityRelationship {
fieldApiName: string;
fieldLabel: string;
targetEntity: string;
relationshipType: 'lookup' | 'master-detail' | 'polymorphic';
}
export interface EntityInfo {
apiName: string;
label: string;
pluralLabel?: string;
description?: string;
fields: EntityFieldInfo[];
requiredFields: string[]; // Field apiNames that are required
relationships: EntityRelationship[];
}
export interface SystemEntities {
entities: EntityInfo[];
entityByApiName: Record<string, EntityInfo>; // Changed from Map for state serialization
loadedAt: number;
}
// ============================================
// Planning Types
// ============================================
export interface PlannedRecord {
id: string; // Temporary ID for planning (e.g., "temp_account_1")
entityApiName: string;
entityLabel: string;
fields: Record<string, any>;
resolvedFields?: Record<string, any>; // Fields after dependency resolution
missingRequiredFields: string[];
dependsOn: string[]; // IDs of other planned records this depends on
status: 'pending' | 'ready' | 'created' | 'failed';
createdRecordId?: string; // Actual ID after creation
wasExisting?: boolean; // True if record already existed in database
error?: string;
}
export interface RecordCreationPlan {
id: string;
records: PlannedRecord[];
executionOrder: string[]; // Ordered list of planned record IDs
status: 'building' | 'incomplete' | 'ready' | 'executing' | 'completed' | 'failed';
createdRecords: any[];
errors: string[];
}
// ============================================
// State Types
// ============================================
export interface AiAssistantState {
message: string;
messages?: any[]; // BaseMessage[] from langchain - used when invoked by Deep Agent
history?: AiChatMessage[];
context: AiChatContext;
// Entity discovery
systemEntities?: SystemEntities;
// Planning
plan?: RecordCreationPlan;
// Legacy fields (kept for compatibility during transition)
objectDefinition?: any;
pageLayout?: any;
extractedFields?: Record<string, any>;
requiredFields?: string[];
missingFields?: string[];
action?: AiAssistantReply['action'];
record?: any;
reply?: string;
}

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ import { RbacModule } from './rbac/rbac.module';
import { ObjectModule } from './object/object.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';
@Module({
imports: [
@@ -22,8 +20,6 @@ import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
ObjectModule,
AppBuilderModule,
PageLayoutModule,
VoiceModule,
AiAssistantModule,
],
})
export class AppModule {}

View File

@@ -3,15 +3,13 @@ import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { VoiceService } from './voice/voice.service';
import { AudioConverterService } from './voice/audio-converter.service';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
new FastifyAdapter(),
);
// Global validation pipe
@@ -35,145 +33,6 @@ async function bootstrap() {
const port = process.env.PORT || 3000;
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`);
}

View File

@@ -1,38 +1,7 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { Model, ModelOptions, QueryContext, snakeCaseMappers } 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;
},
};
static columnNameMappers = snakeCaseMappers();
id: string;
createdAt: Date;

View File

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

View File

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

View File

@@ -30,8 +30,6 @@ export interface UIMetadata {
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.
@@ -76,13 +74,5 @@ export class FieldDefinition extends BaseModel {
to: 'object_definitions.id',
},
},
rolePermissions: {
relation: BaseModel.HasManyRelation,
modelClass: () => require('./role-field-permission.model').RoleFieldPermission,
join: {
from: 'field_definitions.id',
to: 'role_field_permissions.fieldDefinitionId',
},
},
};
}

View File

@@ -10,11 +10,8 @@ export class ObjectDefinition extends BaseModel {
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 {
@@ -28,14 +25,12 @@ export class ObjectDefinition extends BaseModel {
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: {
@@ -46,14 +41,6 @@ export class ObjectDefinition extends BaseModel {
to: 'field_definitions.objectDefinitionId',
},
},
rolePermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RoleObjectPermission,
join: {
from: 'object_definitions.id',
to: 'role_object_permissions.objectDefinitionId',
},
},
};
}
}

View File

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

View File

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

View File

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

View File

@@ -27,8 +27,6 @@ export class Role extends BaseModel {
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: {
@@ -63,22 +61,6 @@ export class Role extends BaseModel {
to: 'users.id',
},
},
objectPermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RoleObjectPermission,
join: {
from: 'roles.id',
to: 'role_object_permissions.roleId',
},
},
fieldPermissions: {
relation: BaseModel.HasManyRelation,
modelClass: RoleFieldPermission,
join: {
from: 'roles.id',
to: 'role_field_permissions.roleId',
},
},
};
}
}

View File

@@ -22,9 +22,7 @@ export interface FieldConfigDTO {
step?: number;
accept?: string;
relationObject?: string;
relationObjects?: string[];
relationDisplayField?: string;
relationTypeField?: string;
format?: string;
prefix?: string;
suffix?: string;
@@ -45,14 +43,6 @@ export interface ObjectDefinitionDTO {
description?: string;
isSystem: boolean;
fields: FieldConfigDTO[];
relatedLists?: Array<{
title: string;
relationName: string;
objectApiName: string;
fields: FieldConfigDTO[];
canCreate?: boolean;
createRoute?: string;
}>;
}
@Injectable()
@@ -61,33 +51,13 @@ export class FieldMapperService {
* Convert a field definition from the database to a frontend-friendly FieldConfig
*/
mapFieldToDTO(field: any): FieldConfigDTO {
// Parse ui_metadata if it's a JSON string or object
let uiMetadata: any = {};
const metadataField = field.ui_metadata || field.uiMetadata;
if (metadataField) {
if (typeof metadataField === 'string') {
try {
uiMetadata = JSON.parse(metadataField);
} catch (e) {
uiMetadata = {};
}
} else {
uiMetadata = metadataField;
}
}
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
// Hide 'id' field from list view by default
const isIdField = field.apiName === 'id';
const defaultShowOnList = isIdField ? false : true;
const uiMetadata = field.uiMetadata || {};
return {
id: field.id,
apiName: field.apiName,
label: field.label,
type: frontendType,
type: this.mapFieldType(field.type),
// Display properties
placeholder: uiMetadata.placeholder || field.description,
@@ -99,7 +69,7 @@ export class FieldMapperService {
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
// View visibility
showOnList: uiMetadata.showOnList !== undefined ? uiMetadata.showOnList : defaultShowOnList,
showOnList: uiMetadata.showOnList !== false,
showOnDetail: uiMetadata.showOnDetail !== false,
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
sortable: uiMetadata.sortable !== false,
@@ -112,12 +82,7 @@ export class FieldMapperService {
step: uiMetadata.step,
accept: uiMetadata.accept,
relationObject: field.referenceObject,
relationObjects: uiMetadata.relationObjects,
// For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
relationTypeField: uiMetadata.relationTypeField,
relationDisplayField: uiMetadata.relationDisplayField,
// Formatting
format: uiMetadata.format,
@@ -145,7 +110,6 @@ export class FieldMapperService {
'boolean': 'boolean',
'date': 'date',
'datetime': 'datetime',
'date_time': 'datetime',
'time': 'time',
'email': 'email',
'url': 'url',
@@ -223,17 +187,6 @@ export class FieldMapperService {
.filter((f: any) => f.isActive !== false)
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map((f: any) => this.mapFieldToDTO(f)),
relatedLists: (objectDef.relatedLists || []).map((list: any) => ({
title: list.title,
relationName: list.relationName,
objectApiName: list.objectApiName,
fields: (list.fields || [])
.filter((f: any) => f.isActive !== false)
.map((f: any) => this.mapFieldToDTO(f))
.filter((f: any) => f.showOnList !== false),
canCreate: list.canCreate,
createRoute: list.createRoute,
})),
};
}

View File

@@ -1,5 +1,4 @@
import { Model } from 'objection';
import { randomUUID } from 'crypto';
/**
* Base model for all dynamic and system models
@@ -11,23 +10,26 @@ export class BaseModel extends Model {
tenantId?: string;
ownerId?: string;
name?: string;
created_at?: string;
updated_at?: string;
created_at?: Date;
updated_at?: Date;
// Hook to set system-managed fields
async $beforeInsert() {
if (!this.id) {
this.id = randomUUID();
}
if (!this.created_at) {
this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
if (!this.updated_at) {
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
$beforeInsert() {
// created_at and updated_at are handled by the database
// ownerId should be set by the controller/service
}
async $beforeUpdate() {
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
$beforeUpdate() {
// updated_at is handled by the database
}
/**
* Get the API name for this object
* Override in subclasses
*/
static get objectApiName(): string {
return 'BaseModel';
}
}

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
import { BaseModel } from './base.model';
@@ -27,23 +28,10 @@ export interface ObjectMetadata {
}
export class DynamicModelFactory {
/**
* Get relation name from lookup field API name
* Converts "ownerId" -> "owner", "customFieldId" -> "customfield"
*/
static getRelationName(lookupFieldApiName: string): string {
return lookupFieldApiName.replace(/Id$/, '').toLowerCase();
}
/**
* Create a dynamic model class from object metadata
* @param meta Object metadata
* @param getModel Function to retrieve model classes from registry
*/
static createModel(
meta: ObjectMetadata,
getModel?: (apiName: string) => ModelClass<any>,
): ModelClass<any> {
static createModel(meta: ObjectMetadata): ModelClass<any> {
const { tableName, fields, apiName, relations = [] } = meta;
// Build JSON schema properties
@@ -56,8 +44,7 @@ export class DynamicModelFactory {
updated_at: { type: 'string', format: 'date-time' },
};
// Don't require id or tenantId - they'll be set automatically
const required: string[] = [];
const required: string[] = ['id', 'tenantId'];
// Add custom fields
for (const field of fields) {
@@ -70,98 +57,27 @@ export class DynamicModelFactory {
}
}
// Build relation mappings from lookup fields
const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
// Build relation mappings
const relationMappings: RelationMappings = {};
for (const rel of relations) {
// Relations are resolved dynamically, skipping for now
// Will be handled by ModelRegistry.getModel()
}
// Store lookup fields metadata for later use
const lookupFieldsInfo = lookupFields.map(f => ({
apiName: f.apiName,
relationName: DynamicModelFactory.getRelationName(f.apiName),
referenceObject: f.referenceObject,
targetTable: this.getTableName(f.referenceObject),
}));
// Create the dynamic model class extending Model directly
class DynamicModel extends Model {
id?: string;
tenantId?: string;
ownerId?: string;
name?: string;
created_at?: string;
updated_at?: string;
// Create the dynamic model class extending BaseModel
class DynamicModel extends BaseModel {
static tableName = tableName;
static objectApiName = apiName;
static lookupFields = lookupFieldsInfo;
static get relationMappings(): RelationMappings {
const mappings: RelationMappings = {};
// Build relation mappings from lookup fields
for (const lookupInfo of lookupFieldsInfo) {
// Use getModel function if provided, otherwise use string reference
let modelClass: any = lookupInfo.referenceObject;
if (getModel) {
const resolvedModel = getModel(lookupInfo.referenceObject);
// Only use resolved model if it exists, otherwise skip this relation
// It will be resolved later when the model is registered
if (resolvedModel) {
modelClass = resolvedModel;
} else {
// Skip this relation if model not found yet
continue;
}
}
mappings[lookupInfo.relationName] = {
relation: Model.BelongsToOneRelation,
modelClass,
join: {
from: `${tableName}.${lookupInfo.apiName}`,
to: `${lookupInfo.targetTable}.id`,
},
};
}
// Add additional relation mappings (e.g., hasMany)
for (const relation of relations) {
if (mappings[relation.name]) {
continue;
}
let modelClass: any = relation.targetObjectApiName;
if (getModel) {
const resolvedModel = getModel(relation.targetObjectApiName);
if (resolvedModel) {
modelClass = resolvedModel;
} else {
continue;
}
}
const targetTable = DynamicModelFactory.getTableName(relation.targetObjectApiName);
if (relation.type === 'belongsTo') {
mappings[relation.name] = {
relation: Model.BelongsToOneRelation,
modelClass,
join: {
from: `${tableName}.${relation.fromColumn}`,
to: `${targetTable}.${relation.toColumn}`,
},
};
}
if (relation.type === 'hasMany') {
mappings[relation.name] = {
relation: Model.HasManyRelation,
modelClass,
join: {
from: `${tableName}.${relation.fromColumn}`,
to: `${targetTable}.${relation.toColumn}`,
},
};
}
}
return mappings;
}
static relationMappings = relationMappings;
static get jsonSchema() {
return {
@@ -170,6 +86,22 @@ export class DynamicModelFactory {
properties,
};
}
async $beforeInsert() {
if (!this.id) {
this.id = randomUUID();
}
if (!this.created_at) {
this.created_at = new Date().toISOString();
}
if (!this.updated_at) {
this.updated_at = new Date().toISOString();
}
}
async $beforeUpdate() {
this.updated_at = new Date().toISOString();
}
}
return DynamicModel as any;
@@ -179,8 +111,7 @@ export class DynamicModelFactory {
* Convert a field definition to JSON schema property
*/
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
const baseSchema = () => {
switch (field.type.toUpperCase()) {
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
case 'EMAIL':
@@ -188,71 +119,44 @@ export class DynamicModelFactory {
case 'PHONE':
case 'PICKLIST':
case 'MULTI_PICKLIST':
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
case 'LONG_TEXT':
return { type: 'string' };
return { type: 'string' };
case 'NUMBER':
case 'DECIMAL':
case 'CURRENCY':
case 'PERCENT':
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
case 'INTEGER':
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
case 'BOOLEAN':
return { type: 'boolean', default: false };
return { type: 'boolean', default: false };
case 'DATE':
return { type: 'string', format: 'date' };
return { type: 'string', format: 'date' };
case 'DATE_TIME':
return { type: 'string', format: 'date-time' };
return { type: 'string', format: 'date-time' };
case 'LOOKUP':
case 'BELONGS_TO':
return { type: 'string' };
return { type: 'string' };
default:
return { type: 'string' };
}
};
const schema = baseSchema();
// Allow null for non-required fields so optional strings/numbers don't fail validation
if (!field.isRequired) {
return {
anyOf: [schema, { type: 'null' }],
};
return { type: 'string' };
}
return schema;
}
/**
* Get table name from object API name
*/
private static getTableName(objectApiName: string): string {
// Convert PascalCase/camelCase to snake_case and pluralize
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
if (snakeCase.endsWith('y')) {
return `${snakeCase.slice(0, -1)}ies`;
}
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
}

View File

@@ -16,17 +16,13 @@ export class ModelRegistry {
*/
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void {
this.registry.set(apiName, modelClass);
const lowerKey = apiName.toLowerCase();
if (lowerKey !== apiName && !this.registry.has(lowerKey)) {
this.registry.set(lowerKey, modelClass);
}
}
/**
* Get a model from the registry
*/
getModel(apiName: string): ModelClass<BaseModel> {
const model = this.registry.get(apiName) || this.registry.get(apiName.toLowerCase());
const model = this.registry.get(apiName);
if (!model) {
throw new Error(`Model for ${apiName} not found in registry`);
}
@@ -37,7 +33,7 @@ export class ModelRegistry {
* Check if a model exists in the registry
*/
hasModel(apiName: string): boolean {
return this.registry.has(apiName) || this.registry.has(apiName.toLowerCase());
return this.registry.has(apiName);
}
/**
@@ -46,13 +42,7 @@ export class ModelRegistry {
createAndRegisterModel(
metadata: ObjectMetadata,
): ModelClass<BaseModel> {
// Create model with a getModel function that resolves from this registry
// Returns undefined if model not found (for models not yet registered)
const model = DynamicModelFactory.createModel(
metadata,
(apiName: string) =>
this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()),
);
const model = DynamicModelFactory.createModel(metadata);
this.registerModel(metadata.apiName, model);
return model;
}

View File

@@ -5,7 +5,6 @@ import { BaseModel } from './base.model';
import { ModelRegistry } from './model.registry';
import { ObjectMetadata } from './dynamic-model.factory';
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
import { UserModel, RoleModel, PermissionModel } from './system-models';
/**
* Service to manage dynamic models for a specific tenant
@@ -22,30 +21,11 @@ export class ModelService {
*/
getTenantRegistry(tenantId: string): ModelRegistry {
if (!this.tenantRegistries.has(tenantId)) {
const registry = new ModelRegistry();
// Register system models that are defined as static Objection models
this.registerSystemModels(registry);
this.tenantRegistries.set(tenantId, registry);
this.tenantRegistries.set(tenantId, new ModelRegistry());
}
return this.tenantRegistries.get(tenantId)!;
}
/**
* Register static system models in the registry
* Uses simplified models without complex relationMappings to avoid modelPath issues
*/
private registerSystemModels(registry: ModelRegistry): void {
// Register system models by their API name (used in referenceObject fields)
// These are simplified versions without relationMappings to avoid dependency issues
registry.registerModel('User', UserModel as any);
registry.registerModel('Role', RoleModel as any);
registry.registerModel('Permission', PermissionModel as any);
this.logger.debug('Registered system models: User, Role, Permission');
}
/**
* Create and register a model for a tenant
*/
@@ -80,24 +60,6 @@ export class ModelService {
): Promise<ModelClass<BaseModel>> {
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const model = this.getModel(tenantId, objectApiName);
// Bind knex to the model and also to all models in the registry
// This ensures system models also have knex bound when they're used in relations
const registry = this.getTenantRegistry(tenantId);
const allModels = registry.getAllModelNames();
// Bind knex to all models to ensure relations work
for (const modelName of allModels) {
try {
const m = registry.getModel(modelName);
if (m && !m.knex()) {
m.knex(knex);
}
} catch (error) {
// Ignore errors for models that don't need binding
}
}
return model.bindKnex(knex);
}
@@ -116,88 +78,4 @@ export class ModelService {
const registry = this.getTenantRegistry(tenantId);
return registry.getAllModelNames();
}
/**
* Ensure a model is registered with all its dependencies.
* This method handles recursive model creation for related objects.
*
* @param tenantId - The tenant ID
* @param objectApiName - The object API name to ensure registration for
* @param fetchMetadata - Callback function to fetch object metadata (provided by ObjectService)
* @param visited - Set to track visited models and prevent infinite loops
*/
async ensureModelWithDependencies(
tenantId: string,
objectApiName: string,
fetchMetadata: (apiName: string) => Promise<ObjectMetadata>,
visited: Set<string> = new Set(),
): Promise<void> {
// Prevent infinite recursion
if (visited.has(objectApiName)) {
return;
}
visited.add(objectApiName);
// Check if model already exists
if (this.hasModel(tenantId, objectApiName)) {
return;
}
try {
// Fetch the object metadata
const objectMetadata = await fetchMetadata(objectApiName);
// Extract lookup fields to find dependencies
const lookupFields = objectMetadata.fields.filter(
f => f.type === 'LOOKUP' && f.referenceObject
);
// Recursively ensure all dependent models are registered first
for (const field of lookupFields) {
if (field.referenceObject) {
try {
await this.ensureModelWithDependencies(
tenantId,
field.referenceObject,
fetchMetadata,
visited,
);
} catch (error) {
// If related object doesn't exist (e.g., system tables), skip it
this.logger.debug(
`Skipping registration of related model ${field.referenceObject}: ${error.message}`
);
}
}
}
if (objectMetadata.relations) {
for (const relation of objectMetadata.relations) {
if (relation.targetObjectApiName) {
try {
await this.ensureModelWithDependencies(
tenantId,
relation.targetObjectApiName,
fetchMetadata,
visited,
);
} catch (error) {
this.logger.debug(
`Skipping registration of related model ${relation.targetObjectApiName}: ${error.message}`
);
}
}
}
}
// Now create and register this model (all dependencies are ready)
await this.createModelForObject(tenantId, objectMetadata);
this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`);
} catch (error) {
this.logger.warn(
`Failed to ensure model for ${objectApiName}: ${error.message}`
);
throw error;
}
}
}

View File

@@ -1,85 +0,0 @@
import { Model } from 'objection';
/**
* Simplified User model for use in dynamic object relations
* This version doesn't include complex relationMappings to avoid modelPath issues
*/
export class UserModel extends Model {
static tableName = 'users';
static objectApiName = 'User';
id!: string;
email!: string;
firstName?: string;
lastName?: string;
name?: string;
isActive!: boolean;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['email'],
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
firstName: { type: 'string' },
lastName: { type: 'string' },
name: { type: 'string' },
isActive: { type: 'boolean' },
},
};
}
// No relationMappings to avoid modelPath resolution issues
// These simplified models are only used for lookup relations from dynamic models
}
/**
* Simplified Role model for use in dynamic object relations
*/
export class RoleModel extends Model {
static tableName = 'roles';
static objectApiName = 'Role';
id!: string;
name!: string;
description?: string;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
};
}
}
/**
* Simplified Permission model for use in dynamic object relations
*/
export class PermissionModel extends Model {
static tableName = 'permissions';
static objectApiName = 'Permission';
id!: string;
name!: string;
description?: string;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
};
}
}

View File

@@ -6,13 +6,11 @@ import { SchemaManagementService } from './schema-management.service';
import { FieldMapperService } from './field-mapper.service';
import { TenantModule } from '../tenant/tenant.module';
import { MigrationModule } from '../migration/migration.module';
import { RbacModule } from '../rbac/rbac.module';
import { ModelRegistry } from './models/model.registry';
import { ModelService } from './models/model.service';
import { MeilisearchModule } from '../search/meilisearch.module';
@Module({
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule],
imports: [TenantModule, MigrationModule],
providers: [
ObjectService,
SchemaManagementService,

File diff suppressed because it is too large Load Diff

View File

@@ -95,20 +95,4 @@ export class RuntimeObjectController {
user.userId,
);
}
@Post(':objectApiName/records/bulk-delete')
async deleteRecords(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() body: { recordIds?: string[]; ids?: string[] },
@CurrentUser() user: any,
) {
const recordIds: string[] = body?.recordIds || body?.ids || [];
return this.objectService.deleteRecords(
tenantId,
objectApiName,
recordIds,
user.userId,
);
}
}

View File

@@ -15,11 +15,7 @@ export class SchemaManagementService {
objectDefinition: ObjectDefinition,
fields: FieldDefinition[],
) {
const tableName = this.getTableName(
objectDefinition.apiName,
objectDefinition.label,
objectDefinition.pluralLabel,
);
const tableName = this.getTableName(objectDefinition.apiName);
// Check if table already exists
const exists = await knex.schema.hasTable(tableName);
@@ -48,10 +44,8 @@ export class SchemaManagementService {
knex: Knex,
objectApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
const tableName = this.getTableName(objectApiName);
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
@@ -67,10 +61,8 @@ export class SchemaManagementService {
knex: Knex,
objectApiName: string,
fieldApiName: string,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
const tableName = this.getTableName(objectApiName);
await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(fieldApiName);
@@ -79,44 +71,11 @@ export class SchemaManagementService {
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
}
/**
* Alter a field in an existing object table
* Handles safe updates like changing NOT NULL or constraints
* Warns about potentially destructive operations
*/
async alterFieldInTable(
knex: Knex,
objectApiName: string,
fieldApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
options?: {
skipTypeChange?: boolean; // Skip if type change would lose data
},
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
const skipTypeChange = options?.skipTypeChange ?? true;
await knex.schema.alterTable(tableName, (table) => {
// Drop the existing column and recreate with new definition
// Note: This approach works for metadata changes, but type changes may need data migration
table.dropColumn(fieldApiName);
});
// Recreate the column with new definition
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
});
this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`);
}
/**
* Drop an object table
*/
async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
async dropObjectTable(knex: Knex, objectApiName: string) {
const tableName = this.getTableName(objectApiName);
await knex.schema.dropTableIfExists(tableName);
@@ -135,30 +94,15 @@ export class SchemaManagementService {
let column: Knex.ColumnBuilder;
switch (field.type) {
// Text types
case 'String':
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
column = table.string(columnName, field.length || 255);
break;
case 'Text':
case 'LONG_TEXT':
column = table.text(columnName);
break;
case 'PICKLIST':
case 'MULTI_PICKLIST':
column = table.string(columnName, 255);
break;
// Numeric types
case 'Number':
case 'NUMBER':
case 'CURRENCY':
case 'PERCENT':
if (field.scale && field.scale > 0) {
column = table.decimal(
columnName,
@@ -171,28 +115,18 @@ export class SchemaManagementService {
break;
case 'Boolean':
case 'BOOLEAN':
column = table.boolean(columnName).defaultTo(false);
break;
// Date types
case 'Date':
case 'DATE':
column = table.date(columnName);
break;
case 'DateTime':
case 'DATE_TIME':
column = table.datetime(columnName);
break;
case 'TIME':
column = table.time(columnName);
break;
// Relationship types
case 'Reference':
case 'LOOKUP':
column = table.uuid(columnName);
if (field.referenceObject) {
const refTableName = this.getTableName(field.referenceObject);
@@ -200,30 +134,19 @@ export class SchemaManagementService {
}
break;
// Email (legacy)
case 'Email':
column = table.string(columnName, 255);
break;
// Phone (legacy)
case 'Phone':
column = table.string(columnName, 50);
break;
// Url (legacy)
case 'Url':
column = table.string(columnName, 255);
break;
// File types
case 'FILE':
case 'IMAGE':
column = table.text(columnName); // Store file path or URL
break;
// JSON
case 'Json':
case 'JSON':
column = table.json(columnName);
break;
@@ -251,35 +174,16 @@ export class SchemaManagementService {
/**
* Convert object API name to table name (convert to snake_case, pluralize)
*/
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
private getTableName(apiName: string): string {
// Convert PascalCase to snake_case
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
return `${snake}s`;
};
const fromApi = toSnakePlural(apiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
}
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
// Simple pluralization (append 's' if not already plural)
// In production, use a proper pluralization library
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
/**

View File

@@ -2,9 +2,6 @@ import {
Controller,
Get,
Post,
Patch,
Put,
Delete,
Param,
Body,
UseGuards,
@@ -13,7 +10,6 @@ import { ObjectService } from './object.service';
import { FieldMapperService } from './field-mapper.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
@Controller('setup/objects')
@UseGuards(JwtAuthGuard)
@@ -21,7 +17,6 @@ export class SetupObjectController {
constructor(
private objectService: ObjectService,
private fieldMapperService: FieldMapperService,
private tenantDbService: TenantDatabaseService,
) {}
@Get()
@@ -34,8 +29,7 @@ export class SetupObjectController {
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
return this.objectService.getObjectDefinition(tenantId, objectApiName);
}
@Get(':objectApiName/ui-config')
@@ -64,93 +58,10 @@ export class SetupObjectController {
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
const field = await this.objectService.createFieldDefinition(
return this.objectService.createFieldDefinition(
tenantId,
objectApiName,
data,
);
// Map the created field to frontend format
return this.fieldMapperService.mapFieldToDTO(field);
}
@Put(':objectApiName/fields/:fieldApiName')
async updateFieldDefinition(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('fieldApiName') fieldApiName: string,
@Body() data: any,
) {
const field = await this.objectService.updateFieldDefinition(
tenantId,
objectApiName,
fieldApiName,
data,
);
return this.fieldMapperService.mapFieldToDTO(field);
}
@Delete(':objectApiName/fields/:fieldApiName')
async deleteFieldDefinition(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('fieldApiName') fieldApiName: string,
) {
return this.objectService.deleteFieldDefinition(
tenantId,
objectApiName,
fieldApiName,
);
}
@Patch(':objectApiName')
async updateObjectDefinition(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
return this.objectService.updateObjectDefinition(tenantId, objectApiName, data);
}
@Get(':objectId/field-permissions')
async getFieldPermissions(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
) {
return this.objectService.getFieldPermissions(tenantId, objectId);
}
@Put(':objectId/field-permissions')
async updateFieldPermission(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
@Body() data: { roleId: string; fieldDefinitionId: string; canRead: boolean; canEdit: boolean },
) {
return this.objectService.updateFieldPermission(tenantId, data.roleId, data.fieldDefinitionId, data.canRead, data.canEdit);
}
@Get(':objectApiName/permissions/:roleId')
async getObjectPermissions(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('roleId') roleId: string,
) {
return this.objectService.getObjectPermissions(tenantId, objectApiName, roleId);
}
@Put(':objectApiName/permissions')
async updateObjectPermissions(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() data: {
roleId: string;
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canViewAll: boolean;
canModifyAll: boolean;
},
) {
return this.objectService.updateObjectPermissions(tenantId, objectApiName, data);
}
}

View File

@@ -1,6 +1,4 @@
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator';
export type PageLayoutType = 'detail' | 'list';
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
export class CreatePageLayoutDto {
@IsString()
@@ -9,27 +7,19 @@ export class CreatePageLayoutDto {
@IsUUID()
objectId: string;
@IsIn(['detail', 'list'])
@IsOptional()
layoutType?: PageLayoutType = 'detail';
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsObject()
layoutConfig: {
// For detail layouts: grid-based field positions
fields: Array<{
fieldId: string;
x?: number;
y?: number;
w?: number;
h?: number;
// For list layouts: field order (optional, defaults to array index)
order?: number;
x: number;
y: number;
w: number;
h: number;
}>;
relatedLists?: string[];
};
@IsString()
@@ -51,13 +41,11 @@ export class UpdatePageLayoutDto {
layoutConfig?: {
fields: Array<{
fieldId: string;
x?: number;
y?: number;
w?: number;
h?: number;
order?: number;
x: number;
y: number;
w: number;
h: number;
}>;
relatedLists?: string[];
};
@IsString()

View File

@@ -10,7 +10,7 @@ import {
Query,
} from '@nestjs/common';
import { PageLayoutService } from './page-layout.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@@ -25,21 +25,13 @@ export class PageLayoutController {
}
@Get()
findAll(
@TenantId() tenantId: string,
@Query('objectId') objectId?: string,
@Query('layoutType') layoutType?: PageLayoutType,
) {
return this.pageLayoutService.findAll(tenantId, objectId, layoutType);
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
return this.pageLayoutService.findAll(tenantId, objectId);
}
@Get('default/:objectId')
findDefaultByObject(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
@Query('layoutType') layoutType?: PageLayoutType,
) {
return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail');
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
}
@Get(':id')

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto, PageLayoutType } from './dto/page-layout.dto';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
@Injectable()
export class PageLayoutService {
@@ -8,19 +8,17 @@ export class PageLayoutService {
async create(tenantId: string, createDto: CreatePageLayoutDto) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const layoutType = createDto.layoutType || 'detail';
// If this layout is set as default, unset other defaults for the same object and layout type
// If this layout is set as default, unset other defaults for the same object
if (createDto.isDefault) {
await knex('page_layouts')
.where({ object_id: createDto.objectId, layout_type: layoutType })
.where({ object_id: createDto.objectId })
.update({ is_default: false });
}
const [id] = await knex('page_layouts').insert({
name: createDto.name,
object_id: createDto.objectId,
layout_type: layoutType,
is_default: createDto.isDefault || false,
layout_config: JSON.stringify(createDto.layoutConfig),
description: createDto.description || null,
@@ -31,7 +29,7 @@ export class PageLayoutService {
return result;
}
async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) {
async findAll(tenantId: string, objectId?: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
let query = knex('page_layouts');
@@ -40,10 +38,6 @@ export class PageLayoutService {
query = query.where({ object_id: objectId });
}
if (layoutType) {
query = query.where({ layout_type: layoutType });
}
const layouts = await query.orderByRaw('is_default DESC, name ASC');
return layouts;
}
@@ -60,11 +54,11 @@ export class PageLayoutService {
return layout;
}
async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') {
async findDefaultByObject(tenantId: string, objectId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const layout = await knex('page_layouts')
.where({ object_id: objectId, is_default: true, layout_type: layoutType })
.where({ object_id: objectId, is_default: true })
.first();
return layout || null;
@@ -74,12 +68,13 @@ export class PageLayoutService {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
// Check if layout exists
const layout = await this.findOne(tenantId, id);
await this.findOne(tenantId, id);
// If setting as default, unset other defaults for the same object and layout type
// If setting as default, unset other defaults for the same object
if (updateDto.isDefault) {
const layout = await this.findOne(tenantId, id);
await knex('page_layouts')
.where({ object_id: layout.object_id, layout_type: layout.layout_type })
.where({ object_id: layout.object_id })
.whereNot({ id })
.update({ is_default: false });
}

View File

@@ -1,199 +0,0 @@
import { AbilityBuilder, PureAbility, AbilityClass } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { User } from '../models/user.model';
import { RoleObjectPermission } from '../models/role-object-permission.model';
import { RoleFieldPermission } from '../models/role-field-permission.model';
import { RecordShare } from '../models/record-share.model';
// Define action types
export type Action = 'create' | 'read' | 'update' | 'delete' | 'view_all' | 'modify_all';
// Define subject types - can be string (object API name) or actual object with fields
export type Subject = string | { objectApiName: string; ownerId?: string; id?: string; [key: string]: any };
// Define field actions
export type FieldAction = 'read' | 'edit';
export type AppAbility = PureAbility<[Action, Subject], { field?: string }>;
@Injectable()
export class AbilityFactory {
/**
* Build CASL ability for a user based on their roles and permissions
* This aggregates permissions from all roles the user has
*/
async defineAbilityFor(
user: User & { roles?: Array<{ objectPermissions?: RoleObjectPermission[]; fieldPermissions?: RoleFieldPermission[] }> },
recordShares?: RecordShare[],
): Promise<AppAbility> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility as AbilityClass<AppAbility>);
if (!user.roles || user.roles.length === 0) {
// No roles = no permissions
return build();
}
// Aggregate object permissions from all roles
const objectPermissionsMap = new Map<string, {
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canViewAll: boolean;
canModifyAll: boolean;
}>();
// Aggregate field permissions from all roles
const fieldPermissionsMap = new Map<string, {
canRead: boolean;
canEdit: boolean;
}>();
// Process all roles
for (const role of user.roles) {
// Aggregate object permissions
if (role.objectPermissions) {
for (const perm of role.objectPermissions) {
const existing = objectPermissionsMap.get(perm.objectDefinitionId) || {
canCreate: false,
canRead: false,
canEdit: false,
canDelete: false,
canViewAll: false,
canModifyAll: false,
};
// Union of permissions (if any role grants it, user has it)
objectPermissionsMap.set(perm.objectDefinitionId, {
canCreate: existing.canCreate || perm.canCreate,
canRead: existing.canRead || perm.canRead,
canEdit: existing.canEdit || perm.canEdit,
canDelete: existing.canDelete || perm.canDelete,
canViewAll: existing.canViewAll || perm.canViewAll,
canModifyAll: existing.canModifyAll || perm.canModifyAll,
});
}
}
// Aggregate field permissions
if (role.fieldPermissions) {
for (const perm of role.fieldPermissions) {
const existing = fieldPermissionsMap.get(perm.fieldDefinitionId) || {
canRead: false,
canEdit: false,
};
fieldPermissionsMap.set(perm.fieldDefinitionId, {
canRead: existing.canRead || perm.canRead,
canEdit: existing.canEdit || perm.canEdit,
});
}
}
}
// Convert aggregated permissions to CASL rules
for (const [objectId, perms] of objectPermissionsMap) {
// Create permission
if (perms.canCreate) {
can('create', objectId);
}
// Read permission
if (perms.canRead) {
can('read', objectId);
}
// View all permission (can see all records regardless of ownership)
if (perms.canViewAll) {
can('view_all', objectId);
}
// Edit permission
if (perms.canEdit) {
can('update', objectId);
}
// Modify all permission (can edit all records regardless of ownership)
if (perms.canModifyAll) {
can('modify_all', objectId);
}
// Delete permission
if (perms.canDelete) {
can('delete', objectId);
}
}
// Add record sharing permissions
if (recordShares) {
for (const share of recordShares) {
// Only add if share is active (not expired, not revoked)
const now = new Date();
const isExpired = share.expiresAt && share.expiresAt < now;
const isRevoked = share.revokedAt !== null;
if (!isExpired && !isRevoked) {
// Note: Record-level sharing will be checked in authorization service
// CASL abilities are primarily for object-level permissions
// Individual record access is validated in applyScopeToQuery
}
}
}
return build();
}
/**
* Check if user can access a specific field
* Returns true if user has permission or if no restriction exists
*/
canAccessField(
fieldDefinitionId: string,
action: FieldAction,
user: User & { roles?: Array<{ fieldPermissions?: RoleFieldPermission[] }> },
): boolean {
if (!user.roles || user.roles.length === 0) {
return false;
}
// Collect all field permissions from all roles
const allFieldPermissions: RoleFieldPermission[] = [];
for (const role of user.roles) {
if (role.fieldPermissions) {
allFieldPermissions.push(...role.fieldPermissions);
}
}
// If there are NO field permissions configured at all, allow by default
if (allFieldPermissions.length === 0) {
return true;
}
// If field permissions exist, check for explicit grants (union of all roles)
for (const role of user.roles) {
if (role.fieldPermissions) {
const fieldPerm = role.fieldPermissions.find(fp => fp.fieldDefinitionId === fieldDefinitionId);
if (fieldPerm) {
if (action === 'read' && fieldPerm.canRead) return true;
if (action === 'edit' && fieldPerm.canEdit) return true;
}
}
}
// No explicit rule for this field but other field permissions exist.
// Default to allow so new fields don't get silently stripped and fail validation.
return true;
}
/**
* Filter fields based on user permissions
* Returns array of field IDs the user can access with the specified action
*/
filterFields(
fieldDefinitionIds: string[],
action: FieldAction,
user: User & { roles?: Array<{ fieldPermissions?: RoleFieldPermission[] }> },
): string[] {
return fieldDefinitionIds.filter(fieldId => this.canAccessField(fieldId, action, user));
}
}

View File

@@ -1,282 +0,0 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import { Knex } from 'knex';
import { User } from '../models/user.model';
import { ObjectDefinition } from '../models/object-definition.model';
import { FieldDefinition } from '../models/field-definition.model';
import { RecordShare } from '../models/record-share.model';
import { AbilityFactory, AppAbility, Action } from './ability.factory';
import { DynamicModelFactory } from '../object/models/dynamic-model.factory';
import { subject } from '@casl/ability';
@Injectable()
export class AuthorizationService {
constructor(private abilityFactory: AbilityFactory) {}
/**
* Apply authorization scope to a query based on OWD and user permissions
* This determines which records the user can see
* Modifies the query in place and returns void
*/
async applyScopeToQuery<T = any>(
query: any, // Accept both Knex and Objection query builders
objectDef: ObjectDefinition,
user: User & { roles?: any[] },
action: Action,
knex: Knex,
): Promise<void> {
// Get user's ability
const recordShares = await this.getActiveRecordShares(objectDef.id, user.id, knex);
const ability = await this.abilityFactory.defineAbilityFor(user, recordShares);
// Check if user has the base permission for this action
// Use object ID, not API name, since permissions are stored by object ID
if (!ability.can(action, objectDef.id)) {
// No permission at all - return empty result
query.where(knex.raw('1 = 0'));
return;
}
// Check special permissions
const hasViewAll = ability.can('view_all', objectDef.id);
const hasModifyAll = ability.can('modify_all', objectDef.id);
// If user has view_all or modify_all, they can see all records
if (hasViewAll || hasModifyAll) {
// No filtering needed
return;
}
// Apply OWD (Org-Wide Default) restrictions
switch (objectDef.orgWideDefault) {
case 'public_read_write':
// Everyone can see all records
return;
case 'public_read':
// Everyone can see all records (write operations checked separately)
return;
case 'private':
default:
// Only owner and explicitly shared records
await this.applyPrivateScope(query, objectDef, user, recordShares, knex);
return;
}
}
/**
* Apply private scope: owner + shared records
*/
private async applyPrivateScope<T = any>(
query: any, // Accept both Knex and Objection query builders
objectDef: ObjectDefinition,
user: User,
recordShares: RecordShare[],
knex: Knex,
): Promise<void> {
const tableName = this.getTableName(objectDef.apiName);
// Check if table has ownerId column
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (!hasOwner && recordShares.length === 0) {
// No ownership and no shares - user can't see anything
query.where(knex.raw('1 = 0'));
return;
}
// Build conditions: ownerId = user OR record shared with user
query.where((builder) => {
if (hasOwner) {
builder.orWhere(`${tableName}.ownerId`, user.id);
}
if (recordShares.length > 0) {
const sharedRecordIds = recordShares.map(share => share.recordId);
builder.orWhereIn(`${tableName}.id`, sharedRecordIds);
}
});
}
/**
* Check if user can perform action on a specific record
*/
async canPerformAction(
action: Action,
objectDef: ObjectDefinition,
record: any,
user: User & { roles?: any[] },
knex: Knex,
): Promise<boolean> {
const recordShares = await this.getActiveRecordShares(objectDef.id, user.id, knex);
const ability = await this.abilityFactory.defineAbilityFor(user, recordShares);
// Check base permission - use object ID not API name
if (!ability.can(action, objectDef.id)) {
return false;
}
// Check special permissions - use object ID not API name
const hasViewAll = ability.can('view_all', objectDef.id);
const hasModifyAll = ability.can('modify_all', objectDef.id);
// canViewAll only grants read access to all records
if (action === 'read' && hasViewAll) {
return true;
}
// canModifyAll grants edit/delete access to all records
if ((action === 'update' || action === 'delete') && hasModifyAll) {
return true;
}
// Check OWD
switch (objectDef.orgWideDefault) {
case 'public_read_write':
return true;
case 'public_read':
if (action === 'read') return true;
// For write actions, check ownership
return record.ownerId === user.id;
case 'private':
default:
// Check ownership
if (record.ownerId === user.id) return true;
// Check if record is shared with user
const share = recordShares.find(s => s.recordId === record.id);
if (share) {
if (action === 'read' && share.accessLevel.canRead) return true;
if (action === 'update' && share.accessLevel.canEdit) return true;
if (action === 'delete' && share.accessLevel.canDelete) return true;
}
return false;
}
}
/**
* Filter data based on field-level permissions
* Removes fields the user cannot read
*/
async filterReadableFields(
data: any,
fields: FieldDefinition[],
user: User & { roles?: any[] },
): Promise<any> {
const filtered: any = {};
// Always include id - it's required for navigation and record identification
if (data.id !== undefined) {
filtered.id = data.id;
}
for (const field of fields) {
if (this.abilityFactory.canAccessField(field.id, 'read', user)) {
if (data[field.apiName] !== undefined) {
filtered[field.apiName] = data[field.apiName];
}
// For lookup fields, also include the related object (e.g., ownerId -> owner)
if (field.type === 'LOOKUP') {
const relationName = DynamicModelFactory.getRelationName(field.apiName);
if (data[relationName] !== undefined) {
filtered[relationName] = data[relationName];
}
}
}
}
return filtered;
}
/**
* Filter data based on field-level permissions
* Removes fields the user cannot edit
*/
async filterEditableFields(
data: any,
fields: FieldDefinition[],
user: User & { roles?: any[] },
): Promise<any> {
const filtered: any = {};
for (const field of fields) {
if (this.abilityFactory.canAccessField(field.id, 'edit', user)) {
if (data[field.apiName] !== undefined) {
filtered[field.apiName] = data[field.apiName];
}
}
}
return filtered;
}
/**
* Get active record shares for a user on an object
*/
private async getActiveRecordShares(
objectDefinitionId: string,
userId: string,
knex: Knex,
): Promise<RecordShare[]> {
const now = new Date();
return await RecordShare.query(knex)
.where('objectDefinitionId', objectDefinitionId)
.where('granteeUserId', userId)
.whereNull('revokedAt')
.where((builder) => {
builder.whereNull('expiresAt').orWhere('expiresAt', '>', now);
});
}
/**
* Check if user has permission to create records
*/
async canCreate(
objectDef: ObjectDefinition,
user: User & { roles?: any[] },
): Promise<boolean> {
const ability = await this.abilityFactory.defineAbilityFor(user, []);
return ability.can('create', objectDef.id);
}
/**
* Throw exception if user cannot perform action
*/
async assertCanPerformAction(
action: Action,
objectDef: ObjectDefinition,
record: any,
user: User & { roles?: any[] },
knex: Knex,
): Promise<void> {
const can = await this.canPerformAction(action, objectDef, record, user, knex);
if (!can) {
throw new ForbiddenException(`You do not have permission to ${action} this record`);
}
}
/**
* Get table name from API name
*/
private getTableName(apiName: string): string {
// Convert CamelCase to snake_case and pluralize
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase;
} else {
return snakeCase + 's';
}
}
}

View File

@@ -1,19 +0,0 @@
import { IsString, IsBoolean, IsOptional, IsDateString } from 'class-validator';
export class CreateRecordShareDto {
@IsString()
granteeUserId: string;
@IsBoolean()
canRead: boolean;
@IsBoolean()
canEdit: boolean;
@IsBoolean()
canDelete: boolean;
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View File

@@ -1,16 +1,8 @@
import { Module } from '@nestjs/common';
import { RbacService } from './rbac.service';
import { AbilityFactory } from './ability.factory';
import { AuthorizationService } from './authorization.service';
import { SetupRolesController } from './setup-roles.controller';
import { SetupUsersController } from './setup-users.controller';
import { RecordSharingController } from './record-sharing.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [SetupRolesController, SetupUsersController, RecordSharingController],
providers: [RbacService, AbilityFactory, AuthorizationService],
exports: [RbacService, AbilityFactory, AuthorizationService],
providers: [RbacService],
exports: [RbacService],
})
export class RbacModule {}

View File

@@ -1,350 +0,0 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
UseGuards,
ForbiddenException,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { RecordShare } from '../models/record-share.model';
import { ObjectDefinition } from '../models/object-definition.model';
import { User } from '../models/user.model';
import { AuthorizationService } from './authorization.service';
import { CreateRecordShareDto } from './dto/create-record-share.dto';
@Controller('runtime/objects/:objectApiName/records/:recordId/shares')
@UseGuards(JwtAuthGuard)
export class RecordSharingController {
constructor(
private tenantDbService: TenantDatabaseService,
private authService: AuthorizationService,
) {}
@Get()
async getRecordShares(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@CurrentUser() currentUser: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object not found');
}
// Get the record to check ownership
const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName)
.where({ id: recordId })
.first();
if (!record) {
throw new Error('Record not found');
}
// Only owner can view shares
if (record.ownerId !== currentUser.userId) {
// Check if user has modify all permission
const user: any = await User.query(knex)
.findById(currentUser.userId)
.withGraphFetched('roles.objectPermissions');
if (!user) {
throw new ForbiddenException('User not found');
}
const hasModifyAll = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
)
);
if (!hasModifyAll) {
throw new ForbiddenException('Only the record owner or users with Modify All permission can view shares');
}
}
// Get all active shares for this record
const shares = await RecordShare.query(knex)
.where({ objectDefinitionId: objectDef.id, recordId })
.whereNull('revokedAt')
.where(builder => {
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
})
.withGraphFetched('[granteeUser]')
.orderBy('createdAt', 'desc');
return shares;
}
@Post()
async createRecordShare(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@CurrentUser() currentUser: any,
@Body() data: CreateRecordShareDto,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object not found');
}
// Get the record to check ownership
const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName)
.where({ id: recordId })
.first();
if (!record) {
throw new Error('Record not found');
}
// Check if user can share - either owner or has modify permissions
const canShare = await this.canUserShareRecord(
currentUser.userId,
record,
objectDef,
knex,
);
if (!canShare) {
throw new ForbiddenException('You do not have permission to share this record');
}
// Cannot share with self
if (data.granteeUserId === currentUser.userId) {
throw new Error('Cannot share record with yourself');
}
// Check if share already exists
const existingShare = await RecordShare.query(knex)
.where({
objectDefinitionId: objectDef.id,
recordId,
granteeUserId: data.granteeUserId,
})
.whereNull('revokedAt')
.first();
if (existingShare) {
// Update existing share
const updated = await RecordShare.query(knex)
.patchAndFetchById(existingShare.id, {
accessLevel: {
canRead: data.canRead,
canEdit: data.canEdit,
canDelete: data.canDelete,
},
// Convert ISO string to MySQL datetime format
expiresAt: data.expiresAt
? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')])
: null,
} as any);
return RecordShare.query(knex)
.findById(updated.id)
.withGraphFetched('[granteeUser]');
}
// Create new share
const share = await RecordShare.query(knex).insertAndFetch({
objectDefinitionId: objectDef.id,
recordId,
granteeUserId: data.granteeUserId,
grantedByUserId: currentUser.userId,
accessLevel: {
canRead: data.canRead,
canEdit: data.canEdit,
canDelete: data.canDelete,
},
// Convert ISO string to MySQL datetime format: YYYY-MM-DD HH:MM:SS
expiresAt: data.expiresAt
? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')])
: null,
} as any);
return RecordShare.query(knex)
.findById(share.id)
.withGraphFetched('[granteeUser]');
}
@Delete(':shareId')
async deleteRecordShare(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
@Param('shareId') shareId: string,
@CurrentUser() currentUser: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object not found');
}
// Get the record to check ownership
const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName)
.where({ id: recordId })
.first();
if (!record) {
throw new Error('Record not found');
}
// Only owner can revoke shares
if (record.ownerId !== currentUser.userId) {
// Check if user has modify all permission
const user: any = await User.query(knex)
.findById(currentUser.userId)
.withGraphFetched('roles.objectPermissions');
if (!user) {
throw new ForbiddenException('User not found');
}
const hasModifyAll = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
)
);
if (!hasModifyAll) {
throw new ForbiddenException('Only the record owner or users with Modify All permission can revoke shares');
}
}
// Revoke the share (soft delete)
await RecordShare.query(knex)
.patchAndFetchById(shareId, {
revokedAt: knex.fn.now() as any,
});
return { success: true };
}
private async canUserShareRecord(
userId: string,
record: any,
objectDef: ObjectDefinition,
knex: any,
): Promise<boolean> {
// Owner can always share
if (record.ownerId === userId) {
return true;
}
// Check if user has modify all or edit permissions
const user: any = await User.query(knex)
.findById(userId)
.withGraphFetched('roles.objectPermissions');
if (!user) {
return false;
}
// Check for canModifyAll permission
const hasModifyAll = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
)
);
if (hasModifyAll) {
return true;
}
// Check for canEdit permission (user needs edit to share)
const hasEdit = user.roles?.some(role =>
role.objectPermissions?.some(
perm => perm.objectDefinitionId === objectDef.id && perm.canEdit
)
);
// If user has edit permission, check if they can actually edit this record
// by using the authorization service
if (hasEdit) {
try {
await this.authService.assertCanPerformAction(
'update',
objectDef,
record,
user,
knex,
);
return true;
} catch {
return false;
}
}
return false;
}
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
return `${snake}s`;
};
const fromApi = toSnakePlural(apiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
}
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
}
}

View File

@@ -1,141 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { Role } from '../models/role.model';
@Controller('setup/roles')
@UseGuards(JwtAuthGuard)
export class SetupRolesController {
constructor(private tenantDbService: TenantDatabaseService) {}
@Get()
async getRoles(@TenantId() tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return await Role.query(knex).select('*').orderBy('name', 'asc');
}
@Get(':id')
async getRole(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return await Role.query(knex).findById(id).withGraphFetched('users');
}
@Post()
async createRole(
@TenantId() tenantId: string,
@Body() data: { name: string; description?: string; guardName?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const role = await Role.query(knex).insert({
name: data.name,
description: data.description,
guardName: data.guardName || 'tenant',
});
return role;
}
@Patch(':id')
async updateRole(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() data: { name?: string; description?: string; guardName?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const updateData: any = {};
if (data.name) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.guardName) updateData.guardName = data.guardName;
const role = await Role.query(knex).patchAndFetchById(id, updateData);
return role;
}
@Delete(':id')
async deleteRole(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Delete role user assignments first
await knex('user_roles').where({ roleId: id }).delete();
// Delete role permissions
await knex('role_permissions').where({ roleId: id }).delete();
await knex('role_object_permissions').where({ roleId: id }).delete();
// Delete the role
await Role.query(knex).deleteById(id);
return { success: true };
}
@Post(':roleId/users')
async addUserToRole(
@TenantId() tenantId: string,
@Param('roleId') roleId: string,
@Body() data: { userId: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Check if assignment already exists
const existing = await knex('user_roles')
.where({ userId: data.userId, roleId })
.first();
if (existing) {
return { success: true, message: 'User already assigned' };
}
await knex('user_roles').insert({
id: knex.raw('(UUID())'),
userId: data.userId,
roleId,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
return { success: true };
}
@Delete(':roleId/users/:userId')
async removeUserFromRole(
@TenantId() tenantId: string,
@Param('roleId') roleId: string,
@Param('userId') userId: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
await knex('user_roles')
.where({ userId, roleId })
.delete();
return { success: true };
}
}

View File

@@ -1,146 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { User } from '../models/user.model';
import * as bcrypt from 'bcrypt';
@Controller('setup/users')
@UseGuards(JwtAuthGuard)
export class SetupUsersController {
constructor(private tenantDbService: TenantDatabaseService) {}
@Get()
async getUsers(@TenantId() tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return await User.query(knex).withGraphFetched('roles');
}
@Get(':id')
async getUser(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return await User.query(knex).findById(id).withGraphFetched('roles');
}
@Post()
async createUser(
@TenantId() tenantId: string,
@Body() data: { email: string; password: string; firstName?: string; lastName?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Hash password
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await User.query(knex).insert({
email: data.email,
password: hashedPassword,
firstName: data.firstName,
lastName: data.lastName,
isActive: true,
});
return user;
}
@Patch(':id')
async updateUser(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() data: { email?: string; password?: string; firstName?: string; lastName?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const updateData: any = {};
if (data.email) updateData.email = data.email;
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
// Hash password if provided
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
}
const user = await User.query(knex).patchAndFetchById(id, updateData);
return user;
}
@Delete(':id')
async deleteUser(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Delete user role assignments first
await knex('user_roles').where({ userId: id }).delete();
// Delete the user
await User.query(knex).deleteById(id);
return { success: true };
}
@Post(':userId/roles')
async addRoleToUser(
@TenantId() tenantId: string,
@Param('userId') userId: string,
@Body() data: { roleId: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Check if assignment already exists
const existing = await knex('user_roles')
.where({ userId, roleId: data.roleId })
.first();
if (existing) {
return { success: true, message: 'Role already assigned' };
}
await knex('user_roles').insert({
id: knex.raw('(UUID())'),
userId,
roleId: data.roleId,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
return { success: true };
}
@Delete(':userId/roles/:roleId')
async removeRoleFromUser(
@TenantId() tenantId: string,
@Param('userId') userId: string,
@Param('roleId') roleId: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
await knex('user_roles')
.where({ userId, roleId })
.delete();
return { success: true };
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { MeilisearchService } from './meilisearch.service';
@Module({
providers: [MeilisearchService],
exports: [MeilisearchService],
})
export class MeilisearchModule {}

View File

@@ -1,244 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import * as http from 'http';
import * as https from 'https';
type MeiliConfig = {
host: string;
apiKey?: string;
indexPrefix: string;
};
@Injectable()
export class MeilisearchService {
private readonly logger = new Logger(MeilisearchService.name);
isEnabled(): boolean {
return Boolean(this.getConfig());
}
async searchRecord(
tenantId: string,
objectApiName: string,
query: string,
displayField?: string,
): Promise<{ id: string; hit: any } | null> {
const config = this.getConfig();
if (!config) return null;
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
console.log('querying Meilisearch index:', { indexName, query, displayField });
try {
const response = await this.requestJson('POST', url, {
q: query,
limit: 5,
}, this.buildHeaders(config));
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch query failed for index ${indexName}: ${response.status}`,
);
return null;
}
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
if (hits.length === 0) return null;
if (displayField) {
const loweredQuery = query.toLowerCase();
const exactMatch = hits.find((hit: any) => {
const value = hit?.[displayField];
return value && String(value).toLowerCase() === loweredQuery;
});
if (exactMatch?.id) {
return { id: exactMatch.id, hit: exactMatch };
}
}
const match = hits[0];
if (match?.id) {
return { id: match.id, hit: match };
}
} catch (error) {
this.logger.warn(`Meilisearch lookup failed: ${error.message}`);
}
return null;
}
async searchRecords(
tenantId: string,
objectApiName: string,
query: string,
options?: { limit?: number; offset?: number },
): Promise<{ hits: any[]; total: number }> {
const config = this.getConfig();
if (!config) return { hits: [], total: 0 };
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
try {
const response = await this.requestJson('POST', url, {
q: query,
limit,
offset,
}, this.buildHeaders(config));
console.log('Meilisearch response body:', response.body);
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch query failed for index ${indexName}: ${response.status}`,
);
return { hits: [], total: 0 };
}
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
const total =
response.body?.estimatedTotalHits ??
response.body?.nbHits ??
hits.length;
return { hits, total };
} catch (error) {
this.logger.warn(`Meilisearch query failed: ${error.message}`);
return { hits: [], total: 0 };
}
}
async upsertRecord(
tenantId: string,
objectApiName: string,
record: Record<string, any>,
fieldsToIndex: string[],
): Promise<void> {
const config = this.getConfig();
if (!config || !record?.id) return;
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents?primaryKey=id`;
const document = this.pickRecordFields(record, fieldsToIndex);
try {
const response = await this.requestJson('POST', url, [document], this.buildHeaders(config));
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch upsert failed for index ${indexName}: ${response.status}`,
);
}
} catch (error) {
this.logger.warn(`Meilisearch upsert failed: ${error.message}`);
}
}
async deleteRecord(
tenantId: string,
objectApiName: string,
recordId: string,
): Promise<void> {
const config = this.getConfig();
if (!config || !recordId) return;
const indexName = this.buildIndexName(config, tenantId, objectApiName);
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents/${encodeURIComponent(recordId)}`;
try {
const response = await this.requestJson('DELETE', url, undefined, this.buildHeaders(config));
if (!this.isSuccessStatus(response.status)) {
this.logger.warn(
`Meilisearch delete failed for index ${indexName}: ${response.status}`,
);
}
} catch (error) {
this.logger.warn(`Meilisearch delete failed: ${error.message}`);
}
}
private getConfig(): MeiliConfig | null {
const host = process.env.MEILI_HOST || process.env.MEILISEARCH_HOST;
if (!host) return null;
const trimmedHost = host.replace(/\/+$/, '');
const apiKey = process.env.MEILI_API_KEY || process.env.MEILISEARCH_API_KEY;
const indexPrefix = process.env.MEILI_INDEX_PREFIX || 'tenant_';
return { host: trimmedHost, apiKey, indexPrefix };
}
private buildIndexName(config: MeiliConfig, tenantId: string, objectApiName: string): string {
return `${config.indexPrefix}${tenantId}_${objectApiName}`.toLowerCase();
}
private buildHeaders(config: MeiliConfig): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (config.apiKey) {
headers['X-Meili-API-Key'] = config.apiKey;
headers.Authorization = `Bearer ${config.apiKey}`;
}
return headers;
}
private pickRecordFields(record: Record<string, any>, fields: string[]): Record<string, any> {
const document: Record<string, any> = { id: record.id };
for (const field of fields) {
if (record[field] !== undefined) {
document[field] = record[field];
}
}
return document;
}
private isSuccessStatus(status: number): boolean {
return status >= 200 && status < 300;
}
private requestJson(
method: 'POST' | 'DELETE',
url: string,
payload: any,
headers: Record<string, string>,
): Promise<{ status: number; body: any }> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const client = parsedUrl.protocol === 'https:' ? https : http;
const request = client.request(
{
method,
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: `${parsedUrl.pathname}${parsedUrl.search}`,
headers,
},
(response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
if (!data) {
resolve({ status: response.statusCode || 0, body: null });
return;
}
try {
const body = JSON.parse(data);
resolve({ status: response.statusCode || 0, body });
} catch (error) {
reject(error);
}
});
},
);
request.on('error', reject);
if (payload !== undefined) {
request.write(JSON.stringify(payload));
}
request.end();
});
}
}

View File

@@ -242,26 +242,4 @@ export class TenantDatabaseService {
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Encrypt integrations config JSON object
* @param config - Plain object containing integration credentials
* @returns Encrypted JSON string
*/
encryptIntegrationsConfig(config: any): string {
if (!config) return null;
const jsonString = JSON.stringify(config);
return this.encryptPassword(jsonString);
}
/**
* Decrypt integrations config JSON string
* @param encryptedConfig - Encrypted JSON string
* @returns Plain object with integration credentials
*/
decryptIntegrationsConfig(encryptedConfig: string): any {
if (!encryptedConfig) return null;
const decrypted = this.decryptPassword(encryptedConfig);
return JSON.parse(decrypted);
}
}

View File

@@ -176,7 +176,7 @@ export class TenantProvisioningService {
* Seed default data for new tenant
*/
private async seedDefaultData(tenantId: string) {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId);
try {
// Create default roles

View File

@@ -1,155 +0,0 @@
import {
Controller,
Get,
Put,
Body,
UseGuards,
Req,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantDatabaseService } from './tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import { TenantId } from './tenant.decorator';
@Controller('tenant')
@UseGuards(JwtAuthGuard)
export class TenantController {
constructor(private readonly tenantDbService: TenantDatabaseService) {}
/**
* Get integrations configuration for the current tenant
*/
@Get('integrations')
async getIntegrationsConfig(@TenantId() domain: string) {
const centralPrisma = getCentralPrisma();
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
if (!domainRecord?.tenant || !domainRecord.tenant.integrationsConfig) {
return { data: null };
}
// Decrypt the config
const config = this.tenantDbService.decryptIntegrationsConfig(
domainRecord.tenant.integrationsConfig as any,
);
// Return config with sensitive fields masked
const maskedConfig = this.maskSensitiveFields(config);
return { data: maskedConfig };
}
/**
* Update integrations configuration for the current tenant
*/
@Put('integrations')
async updateIntegrationsConfig(
@TenantId() domain: string,
@Body() body: { integrationsConfig: any },
) {
const { integrationsConfig } = body;
if (!domain) {
throw new Error('Domain is missing from request');
}
// Look up tenant by domain
const centralPrisma = getCentralPrisma();
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
if (!domainRecord?.tenant) {
throw new Error(`Tenant with domain ${domain} not found`);
}
// Merge with existing config to preserve masked values
let finalConfig = integrationsConfig;
if (domainRecord.tenant.integrationsConfig) {
const existingConfig = this.tenantDbService.decryptIntegrationsConfig(
domainRecord.tenant.integrationsConfig as any,
);
// Replace masked values with actual values from existing config
finalConfig = this.unmaskConfig(integrationsConfig, existingConfig);
}
// Encrypt the config
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
finalConfig,
);
// Update in database
await centralPrisma.tenant.update({
where: { id: domainRecord.tenant.id },
data: {
integrationsConfig: encryptedConfig as any,
},
});
return {
success: true,
message: 'Integrations configuration updated successfully',
};
}
/**
* Unmask config by replacing masked values with actual values from existing config
*/
private unmaskConfig(newConfig: any, existingConfig: any): any {
const result = { ...newConfig };
// Unmask Twilio credentials
if (result.twilio && existingConfig.twilio) {
if (result.twilio.authToken === '••••••••' && existingConfig.twilio.authToken) {
result.twilio.authToken = existingConfig.twilio.authToken;
}
if (result.twilio.apiSecret === '••••••••' && existingConfig.twilio.apiSecret) {
result.twilio.apiSecret = existingConfig.twilio.apiSecret;
}
}
// Unmask OpenAI credentials
if (result.openai && existingConfig.openai) {
if (result.openai.apiKey === '••••••••' && existingConfig.openai.apiKey) {
result.openai.apiKey = existingConfig.openai.apiKey;
}
}
return result;
}
/**
* Mask sensitive fields for API responses
*/
private maskSensitiveFields(config: any): any {
if (!config) return null;
const masked = { ...config };
// Mask Twilio credentials
if (masked.twilio) {
masked.twilio = {
...masked.twilio,
authToken: masked.twilio.authToken ? '••••••••' : '',
apiSecret: masked.twilio.apiSecret ? '••••••••' : '',
};
}
// Mask OpenAI credentials
if (masked.openai) {
masked.openai = {
...masked.openai,
apiKey: masked.openai.apiKey ? '••••••••' : '',
};
}
return masked;
}
}

View File

@@ -4,12 +4,11 @@ import { TenantDatabaseService } from './tenant-database.service';
import { TenantProvisioningService } from './tenant-provisioning.service';
import { TenantProvisioningController } from './tenant-provisioning.controller';
import { CentralAdminController } from './central-admin.controller';
import { TenantController } from './tenant.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TenantProvisioningController, CentralAdminController, TenantController],
controllers: [TenantProvisioningController, CentralAdminController],
providers: [
TenantDatabaseService,
TenantProvisioningService,

View File

@@ -1,214 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
/**
* Audio format converter for Twilio <-> OpenAI audio streaming
*
* Twilio Media Streams format:
* - Codec: μ-law (G.711)
* - Sample rate: 8kHz
* - Encoding: base64
* - Chunk size: 20ms (160 bytes)
*
* OpenAI Realtime API format:
* - Codec: PCM16
* - Sample rate: 24kHz
* - Encoding: base64
* - Mono channel
*/
@Injectable()
export class AudioConverterService {
private readonly logger = new Logger(AudioConverterService.name);
// μ-law decode lookup table
private readonly MULAW_DECODE_TABLE = this.buildMuLawDecodeTable();
// μ-law encode lookup table
private readonly MULAW_ENCODE_TABLE = this.buildMuLawEncodeTable();
/**
* Build μ-law to linear PCM16 decode table
*/
private buildMuLawDecodeTable(): Int16Array {
const table = new Int16Array(256);
for (let i = 0; i < 256; i++) {
const mulaw = ~i;
const exponent = (mulaw >> 4) & 0x07;
const mantissa = mulaw & 0x0f;
let sample = (mantissa << 3) + 0x84;
sample <<= exponent;
sample -= 0x84;
if ((mulaw & 0x80) === 0) {
sample = -sample;
}
table[i] = sample;
}
return table;
}
/**
* Build linear PCM16 to μ-law encode table
*/
private buildMuLawEncodeTable(): Uint8Array {
const table = new Uint8Array(65536);
for (let i = 0; i < 65536; i++) {
const sample = (i - 32768);
const sign = sample < 0 ? 0x80 : 0x00;
const magnitude = Math.abs(sample);
// Add bias
let biased = magnitude + 0x84;
// Find exponent
let exponent = 7;
for (let exp = 0; exp < 8; exp++) {
if (biased <= (0xff << exp)) {
exponent = exp;
break;
}
}
// Extract mantissa
const mantissa = (biased >> (exponent + 3)) & 0x0f;
// Combine sign, exponent, mantissa
const mulaw = ~(sign | (exponent << 4) | mantissa);
table[i] = mulaw & 0xff;
}
return table;
}
/**
* Decode μ-law audio to linear PCM16
* @param mulawData - Buffer containing μ-law encoded audio
* @returns Buffer containing PCM16 audio (16-bit little-endian)
*/
decodeMuLaw(mulawData: Buffer): Buffer {
const pcm16 = Buffer.allocUnsafe(mulawData.length * 2);
for (let i = 0; i < mulawData.length; i++) {
const sample = this.MULAW_DECODE_TABLE[mulawData[i]];
pcm16.writeInt16LE(sample, i * 2);
}
return pcm16;
}
/**
* Encode linear PCM16 to μ-law
* @param pcm16Data - Buffer containing PCM16 audio (16-bit little-endian)
* @returns Buffer containing μ-law encoded audio
*/
encodeMuLaw(pcm16Data: Buffer): Buffer {
const mulaw = Buffer.allocUnsafe(pcm16Data.length / 2);
for (let i = 0; i < pcm16Data.length; i += 2) {
const sample = pcm16Data.readInt16LE(i);
const index = (sample + 32768) & 0xffff;
mulaw[i / 2] = this.MULAW_ENCODE_TABLE[index];
}
return mulaw;
}
/**
* Resample audio from 8kHz to 24kHz (linear interpolation)
* @param pcm16Data - Buffer containing 8kHz PCM16 audio
* @returns Buffer containing 24kHz PCM16 audio
*/
resample8kTo24k(pcm16Data: Buffer): Buffer {
const inputSamples = pcm16Data.length / 2;
const outputSamples = Math.floor(inputSamples * 3); // 8k * 3 = 24k
const output = Buffer.allocUnsafe(outputSamples * 2);
for (let i = 0; i < outputSamples; i++) {
const srcIndex = i / 3;
const srcIndexFloor = Math.floor(srcIndex);
const srcIndexCeil = Math.min(srcIndexFloor + 1, inputSamples - 1);
const fraction = srcIndex - srcIndexFloor;
const sample1 = pcm16Data.readInt16LE(srcIndexFloor * 2);
const sample2 = pcm16Data.readInt16LE(srcIndexCeil * 2);
// Linear interpolation
const interpolated = Math.round(sample1 + (sample2 - sample1) * fraction);
output.writeInt16LE(interpolated, i * 2);
}
return output;
}
/**
* Resample audio from 24kHz to 8kHz (decimation with averaging)
* @param pcm16Data - Buffer containing 24kHz PCM16 audio
* @returns Buffer containing 8kHz PCM16 audio
*/
resample24kTo8k(pcm16Data: Buffer): Buffer {
const inputSamples = pcm16Data.length / 2;
const outputSamples = Math.floor(inputSamples / 3); // 24k / 3 = 8k
const output = Buffer.allocUnsafe(outputSamples * 2);
for (let i = 0; i < outputSamples; i++) {
// Average 3 samples for anti-aliasing
const idx1 = Math.min(i * 3, inputSamples - 1);
const idx2 = Math.min(i * 3 + 1, inputSamples - 1);
const idx3 = Math.min(i * 3 + 2, inputSamples - 1);
const sample1 = pcm16Data.readInt16LE(idx1 * 2);
const sample2 = pcm16Data.readInt16LE(idx2 * 2);
const sample3 = pcm16Data.readInt16LE(idx3 * 2);
const averaged = Math.round((sample1 + sample2 + sample3) / 3);
output.writeInt16LE(averaged, i * 2);
}
return output;
}
/**
* Convert Twilio μ-law 8kHz to OpenAI PCM16 24kHz
* @param twilioBase64 - Base64-encoded μ-law audio from Twilio
* @returns Base64-encoded PCM16 24kHz audio for OpenAI
*/
twilioToOpenAI(twilioBase64: string): string {
try {
// Decode base64
const mulawBuffer = Buffer.from(twilioBase64, 'base64');
// μ-law -> PCM16
const pcm16_8k = this.decodeMuLaw(mulawBuffer);
// 8kHz -> 24kHz
const pcm16_24k = this.resample8kTo24k(pcm16_8k);
// Encode to base64
return pcm16_24k.toString('base64');
} catch (error) {
this.logger.error('Error converting Twilio to OpenAI audio', error);
throw error;
}
}
/**
* Convert OpenAI PCM16 24kHz to Twilio μ-law 8kHz
* @param openaiBase64 - Base64-encoded PCM16 24kHz audio from OpenAI
* @returns Base64-encoded μ-law 8kHz audio for Twilio
*/
openAIToTwilio(openaiBase64: string): string {
try {
// Decode base64
const pcm16_24k = Buffer.from(openaiBase64, 'base64');
// 24kHz -> 8kHz
const pcm16_8k = this.resample24kTo8k(pcm16_24k);
// PCM16 -> μ-law
const mulawBuffer = this.encodeMuLaw(pcm16_8k);
// Encode to base64
return mulawBuffer.toString('base64');
} catch (error) {
this.logger.error('Error converting OpenAI to Twilio audio', error);
throw error;
}
}
}

View File

@@ -1,25 +0,0 @@
export interface CallEventDto {
callSid: string;
direction: 'inbound' | 'outbound';
fromNumber: string;
toNumber: string;
status: string;
}
export interface DtmfEventDto {
callSid: string;
digit: string;
}
export interface TranscriptEventDto {
callSid: string;
transcript: string;
isFinal: boolean;
}
export interface AiSuggestionDto {
callSid: string;
suggestion: string;
type: 'response' | 'action' | 'insight';
data?: any;
}

View File

@@ -1,10 +0,0 @@
import { IsString, IsNotEmpty, Matches } from 'class-validator';
export class InitiateCallDto {
@IsString()
@IsNotEmpty()
@Matches(/^\+?[1-9]\d{1,14}$/, {
message: 'Invalid phone number format (use E.164 format)',
})
toNumber: string;
}

View File

@@ -1,20 +0,0 @@
export interface TwilioConfig {
accountSid: string;
authToken: string;
phoneNumber: string;
apiKey?: string; // API Key SID for generating access tokens
apiSecret?: string; // API Key Secret
twimlAppSid?: string; // TwiML App SID for Voice SDK
}
export interface OpenAIConfig {
apiKey: string;
assistantId?: string;
model?: string;
voice?: string;
}
export interface IntegrationsConfig {
twilio?: TwilioConfig;
openai?: OpenAIConfig;
}

View File

@@ -1,495 +0,0 @@
import {
Controller,
Post,
Get,
Body,
Req,
Res,
UseGuards,
Logger,
Query,
} from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { VoiceService } from './voice.service';
import { VoiceGateway } from './voice.gateway';
import { AudioConverterService } from './audio-converter.service';
import { InitiateCallDto } from './dto/initiate-call.dto';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('voice')
export class VoiceController {
private readonly logger = new Logger(VoiceController.name);
// Track active Media Streams connections: streamSid -> WebSocket
private mediaStreams: Map<string, any> = new Map();
constructor(
private readonly voiceService: VoiceService,
private readonly voiceGateway: VoiceGateway,
private readonly audioConverter: AudioConverterService,
) {}
/**
* Initiate outbound call via REST
*/
@Post('call')
@UseGuards(JwtAuthGuard)
async initiateCall(
@Body() body: InitiateCallDto,
@Req() req: any,
@TenantId() tenantId: string,
) {
const userId = req.user?.userId || req.user?.sub;
const result = await this.voiceService.initiateCall({
tenantId,
userId,
toNumber: body.toNumber,
});
return {
success: true,
data: result,
};
}
/**
* Generate Twilio access token for browser client
*/
@Get('token')
@UseGuards(JwtAuthGuard)
async getAccessToken(
@Req() req: any,
@TenantId() tenantId: string,
) {
const userId = req.user?.userId || req.user?.sub;
const token = await this.voiceService.generateAccessToken(tenantId, userId);
return {
success: true,
data: { token },
};
}
/**
* Get call history
*/
@Get('calls')
@UseGuards(JwtAuthGuard)
async getCallHistory(
@Req() req: any,
@TenantId() tenantId: string,
@Query('limit') limit?: string,
) {
const userId = req.user?.userId || req.user?.sub;
const calls = await this.voiceService.getCallHistory(
tenantId,
userId,
limit ? parseInt(limit) : 50,
);
return {
success: true,
data: calls,
};
}
/**
* TwiML for outbound calls from browser (Twilio Device)
*/
@Post('twiml/outbound')
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
const to = body.To;
const from = body.From;
const callSid = body.CallSid;
this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`);
this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`);
this.logger.log(`Full body: ${JSON.stringify(body)}`);
try {
// Extract tenant domain from Host header
const host = req.headers.host || '';
const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
// Look up tenant's Twilio phone number from config
let callerId = to; // Fallback (will cause error if not found)
try {
// Get Twilio config to find the phone number
const { config } = await this.voiceService['getTwilioClient'](tenantDomain);
callerId = config.phoneNumber;
this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`);
} catch (error: any) {
this.logger.error(`Failed to get Twilio config: ${error.message}`);
}
const dialNumber = to;
this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`);
// Return TwiML to DIAL the phone number with proper callerId
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial callerId="${callerId}">
<Number>${dialNumber}</Number>
</Dial>
</Response>`;
this.logger.log(`Returning TwiML with Dial verb - callerId: ${callerId}, to: ${dialNumber}`);
res.type('text/xml').send(twiml);
} catch (error: any) {
this.logger.error(`=== ERROR GENERATING TWIML ===`);
this.logger.error(`Error: ${error.message}`);
this.logger.error(`Stack: ${error.stack}`);
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>An error occurred while processing your call.</Say>
</Response>`;
res.type('text/xml').send(errorTwiml);
}
}
/**
* TwiML for inbound calls
*/
@Post('twiml/inbound')
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
const callSid = body.CallSid;
const fromNumber = body.From;
const toNumber = body.To;
this.logger.log(`\n\n╔════════════════════════════════════════╗`);
this.logger.log(`║ === INBOUND CALL RECEIVED ===`);
this.logger.log(`╚════════════════════════════════════════╝`);
this.logger.log(`CallSid: ${callSid}`);
this.logger.log(`From: ${fromNumber}`);
this.logger.log(`To: ${toNumber}`);
this.logger.log(`Full body: ${JSON.stringify(body)}`);
try {
// Extract tenant domain from Host header
const host = req.headers.host || '';
const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
// Get all connected users for this tenant
const connectedUsers = this.voiceGateway.getConnectedUsers(tenantDomain);
this.logger.log(`Connected users for tenant ${tenantDomain}: ${connectedUsers.length}`);
if (connectedUsers.length > 0) {
this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`);
}
if (connectedUsers.length === 0) {
// No users online - send to voicemail or play message
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Sorry, no agents are currently available. Please try again later.</Say>
<Hangup/>
</Response>`;
this.logger.log(`❌ No users online - returning unavailable message`);
return res.type('text/xml').send(twiml);
}
// Build TwiML to dial all connected clients with Media Streams for AI
const clientElements = connectedUsers.map(userId => ` <Client>${userId}</Client>`).join('\n');
// Use wss:// for secure WebSocket (Traefik handles HTTPS)
const streamUrl = `wss://${host}/api/voice/media-stream`;
this.logger.log(`Stream URL: ${streamUrl}`);
this.logger.log(`Dialing ${connectedUsers.length} client(s)...`);
this.logger.log(`Client IDs to dial: ${connectedUsers.join(', ')}`);
// Verify we have client IDs in proper format
if (connectedUsers.length > 0) {
this.logger.log(`First Client ID format check: "${connectedUsers[0]}" (length: ${connectedUsers[0].length})`);
}
// Notify connected users about incoming call via Socket.IO
connectedUsers.forEach(userId => {
this.voiceGateway.notifyIncomingCall(userId, {
callSid,
fromNumber,
toNumber,
tenantDomain,
});
});
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Start>
<Stream url="${streamUrl}">
<Parameter name="tenantId" value="${tenantDomain}"/>
<Parameter name="userId" value="${connectedUsers[0]}"/>
</Stream>
</Start>
<Dial timeout="30">
${clientElements}
</Dial>
</Response>`;
this.logger.log(`✓ Returning inbound TwiML with Media Streams - dialing ${connectedUsers.length} client(s)`);
this.logger.log(`Generated TwiML:\n${twiml}\n`);
res.type('text/xml').send(twiml);
} catch (error: any) {
this.logger.error(`Error generating inbound TwiML: ${error.message}`);
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Sorry, we are unable to connect your call at this time.</Say>
<Hangup/>
</Response>`;
res.type('text/xml').send(errorTwiml);
}
}
/**
* Twilio status webhook
*/
@Post('webhook/status')
async statusWebhook(@Req() req: FastifyRequest) {
const body = req.body as any;
const callSid = body.CallSid;
const status = body.CallStatus;
const duration = body.CallDuration ? parseInt(body.CallDuration) : undefined;
this.logger.log(`Call status webhook - CallSid: ${callSid}, Status: ${status}, Duration: ${duration}`);
this.logger.log(`Full status webhook body:`, JSON.stringify(body));
return { success: true };
}
/**
* Twilio recording webhook
*/
@Post('webhook/recording')
async recordingWebhook(@Req() req: FastifyRequest) {
const body = req.body as any;
const callSid = body.CallSid;
const recordingSid = body.RecordingSid;
const recordingStatus = body.RecordingStatus;
this.logger.log(`Recording webhook - CallSid: ${callSid}, RecordingSid: ${recordingSid}, Status: ${recordingStatus}`);
return { success: true };
}
/**
* Twilio Media Streams WebSocket endpoint
* Receives real-time audio from Twilio and forwards to OpenAI Realtime API
*
* This handles the HTTP GET request and upgrades it to WebSocket manually.
*/
@Get('media-stream')
mediaStream(@Req() req: FastifyRequest) {
// For WebSocket upgrade, we need to access the raw socket
let socket: any;
try {
this.logger.log(`=== MEDIA STREAM REQUEST ===`);
this.logger.log(`URL: ${req.url}`);
this.logger.log(`Headers keys: ${Object.keys(req.headers).join(', ')}`);
this.logger.log(`Headers: ${JSON.stringify(req.headers)}`);
// Check if this is a WebSocket upgrade request
const hasWebSocketKey = 'sec-websocket-key' in req.headers;
const hasWebSocketVersion = 'sec-websocket-version' in req.headers;
this.logger.log(`hasWebSocketKey: ${hasWebSocketKey}`);
this.logger.log(`hasWebSocketVersion: ${hasWebSocketVersion}`);
if (!hasWebSocketKey || !hasWebSocketVersion) {
this.logger.log('Not a WebSocket upgrade request - returning');
return;
}
this.logger.log('✓ WebSocket upgrade detected');
// Get the socket - try different ways
socket = (req.raw as any).socket;
this.logger.log(`Socket obtained: ${!!socket}`);
if (!socket) {
this.logger.error('Failed to get socket from req.raw');
return;
}
const rawRequest = req.raw;
const head = Buffer.alloc(0);
this.logger.log('Creating WebSocketServer...');
const WebSocketServer = require('ws').Server;
const wss = new WebSocketServer({ noServer: true });
this.logger.log('Calling handleUpgrade...');
// handleUpgrade will send the 101 response and take over the socket
wss.handleUpgrade(rawRequest, socket, head, (ws: any) => {
this.logger.log('=== TWILIO MEDIA STREAM WEBSOCKET UPGRADED SUCCESSFULLY ===');
this.handleMediaStreamSocket(ws);
});
this.logger.log('handleUpgrade completed');
} catch (error: any) {
this.logger.error(`=== FAILED TO UPGRADE TO WEBSOCKET ===`);
this.logger.error(`Error message: ${error.message}`);
this.logger.error(`Error stack: ${error.stack}`);
}
}
/**
* Handle incoming Media Stream WebSocket messages
*/
private handleMediaStreamSocket(ws: any) {
let streamSid: string | null = null;
let callSid: string | null = null;
let tenantDomain: string | null = null;
let mediaPacketCount = 0;
// WebSocket message handler
ws.on('message', async (message: Buffer) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.event) {
case 'connected':
this.logger.log('=== MEDIA STREAM EVENT: CONNECTED ===');
this.logger.log(`Protocol: ${msg.protocol}`);
this.logger.log(`Version: ${msg.version}`);
break;
case 'start':
streamSid = msg.streamSid;
callSid = msg.start.callSid;
// Extract tenant from customParameters if available
tenantDomain = msg.start.customParameters?.tenantId || 'tenant1';
this.logger.log(`=== MEDIA STREAM EVENT: START ===`);
this.logger.log(`StreamSid: ${streamSid}`);
this.logger.log(`CallSid: ${callSid}`);
this.logger.log(`Tenant: ${tenantDomain}`);
this.logger.log(`AccountSid: ${msg.start.accountSid}`);
this.logger.log(`MediaFormat: ${JSON.stringify(msg.start.mediaFormat)}`);
this.logger.log(`Custom Parameters: ${JSON.stringify(msg.start.customParameters)}`);
// Store WebSocket connection
this.mediaStreams.set(streamSid, ws);
this.logger.log(`Stored WebSocket for streamSid: ${streamSid}. Total active streams: ${this.mediaStreams.size}`);
// Initialize OpenAI Realtime connection for this call
this.logger.log(`Initializing OpenAI Realtime for call ${callSid}...`);
await this.voiceService.initializeOpenAIRealtime({
callSid,
tenantId: tenantDomain,
userId: msg.start.customParameters?.userId || 'system',
});
this.logger.log(`✓ OpenAI Realtime initialized for call ${callSid}`);
break;
case 'media':
mediaPacketCount++;
if (mediaPacketCount % 50 === 0) {
// Log every 50th packet to avoid spam
this.logger.log(`Received media packet #${mediaPacketCount} for StreamSid: ${streamSid}, CallSid: ${callSid}, PayloadSize: ${msg.media.payload?.length || 0} bytes`);
}
if (!callSid || !tenantDomain) {
this.logger.warn('Received media before start event');
break;
}
// msg.media.payload is base64-encoded μ-law audio from Twilio
const twilioAudio = msg.media.payload;
// Convert Twilio audio (μ-law 8kHz) to OpenAI format (PCM16 24kHz)
const openaiAudio = this.audioConverter.twilioToOpenAI(twilioAudio);
// Send audio to OpenAI Realtime API
await this.voiceService.sendAudioToOpenAI(callSid, openaiAudio);
break;
case 'stop':
this.logger.log(`=== MEDIA STREAM EVENT: STOP ===`);
this.logger.log(`StreamSid: ${streamSid}`);
this.logger.log(`Total media packets received: ${mediaPacketCount}`);
if (streamSid) {
this.mediaStreams.delete(streamSid);
this.logger.log(`Removed WebSocket for streamSid: ${streamSid}. Remaining active streams: ${this.mediaStreams.size}`);
}
// Clean up OpenAI connection
if (callSid) {
this.logger.log(`Cleaning up OpenAI connection for call ${callSid}...`);
await this.voiceService.cleanupOpenAIConnection(callSid);
this.logger.log(`✓ OpenAI connection cleaned up for call ${callSid}`);
}
break;
default:
this.logger.debug(`Unknown media stream event: ${msg.event}`);
}
} catch (error: any) {
this.logger.error(`Error processing media stream message: ${error.message}`);
this.logger.error(`Stack: ${error.stack}`);
}
});
ws.on('close', () => {
this.logger.log(`=== MEDIA STREAM WEBSOCKET CLOSED ===`);
this.logger.log(`StreamSid: ${streamSid}`);
this.logger.log(`Total media packets in this stream: ${mediaPacketCount}`);
if (streamSid) {
this.mediaStreams.delete(streamSid);
this.logger.log(`Cleaned up streamSid on close. Remaining active streams: ${this.mediaStreams.size}`);
}
});
ws.on('error', (error: Error) => {
this.logger.error(`=== MEDIA STREAM WEBSOCKET ERROR ===`);
this.logger.error(`StreamSid: ${streamSid}`);
this.logger.error(`Error message: ${error.message}`);
this.logger.error(`Error stack: ${error.stack}`);
});
}
/**
* Send audio from OpenAI back to Twilio Media Stream
*/
async sendAudioToTwilio(streamSid: string, openaiAudioBase64: string) {
const ws = this.mediaStreams.get(streamSid);
if (!ws) {
this.logger.warn(`No Media Stream found for streamSid: ${streamSid}`);
return;
}
try {
// Convert OpenAI audio (PCM16 24kHz) to Twilio format (μ-law 8kHz)
const twilioAudio = this.audioConverter.openAIToTwilio(openaiAudioBase64);
// Send to Twilio Media Stream
const message = {
event: 'media',
streamSid,
media: {
payload: twilioAudio,
},
};
ws.send(JSON.stringify(message));
} catch (error: any) {
this.logger.error(`Error sending audio to Twilio: ${error.message}`);
}
}
}

View File

@@ -1,319 +0,0 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { VoiceService } from './voice.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
interface AuthenticatedSocket extends Socket {
tenantId?: string;
userId?: string;
tenantSlug?: string;
}
@WebSocketGateway({
namespace: 'voice',
cors: {
origin: true,
credentials: true,
},
})
export class VoiceGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(VoiceGateway.name);
private connectedUsers: Map<string, AuthenticatedSocket> = new Map();
private activeCallsByUser: Map<string, string> = new Map(); // userId -> callSid
constructor(
private readonly jwtService: JwtService,
private readonly voiceService: VoiceService,
private readonly tenantDbService: TenantDatabaseService,
) {
// Set gateway reference in service to avoid circular dependency
this.voiceService.setGateway(this);
}
async handleConnection(client: AuthenticatedSocket) {
try {
// Extract token from handshake auth
const token =
client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
if (!token) {
this.logger.warn('❌ Client connection rejected: No token provided');
client.disconnect();
return;
}
// Verify JWT token
const payload = await this.jwtService.verifyAsync(token);
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
// The domains table stores just the subdomain part (e.g., "tenant1")
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
let domain = 'localhost';
if (origin) {
try {
const url = new URL(origin);
const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
// Extract first part of subdomain as domain
// tenant1.routebox.co -> tenant1
// localhost -> localhost
domain = hostname.split('.')[0];
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
}
client.tenantId = domain; // Store the subdomain as tenantId
client.userId = payload.sub;
client.tenantSlug = domain; // Same as subdomain
this.connectedUsers.set(client.userId, client);
this.logger.log(
`✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`,
);
this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`);
// Send current call state if any active call
const activeCallSid = this.activeCallsByUser.get(client.userId);
if (activeCallSid) {
const callState = await this.voiceService.getCallState(
activeCallSid,
client.tenantId,
);
client.emit('call:state', callState);
}
} catch (error) {
this.logger.error('❌ Authentication failed', error);
client.disconnect();
}
}
handleDisconnect(client: AuthenticatedSocket) {
if (client.userId) {
this.connectedUsers.delete(client.userId);
this.logger.log(`✓ Client disconnected: ${client.id} (User: ${client.userId})`);
this.logger.log(`Remaining connected users: ${this.connectedUsers.size}`);
}
}
/**
* Initiate outbound call
*/
@SubscribeMessage('call:initiate')
async handleInitiateCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { toNumber: string },
) {
try {
this.logger.log(`Initiating call from user ${client.userId} to ${data.toNumber}`);
const result = await this.voiceService.initiateCall({
tenantId: client.tenantId,
userId: client.userId,
toNumber: data.toNumber,
});
this.activeCallsByUser.set(client.userId, result.callSid);
client.emit('call:initiated', {
callSid: result.callSid,
toNumber: data.toNumber,
status: 'queued',
});
return { success: true, callSid: result.callSid };
} catch (error) {
this.logger.error('Failed to initiate call', error);
client.emit('call:error', {
message: error.message || 'Failed to initiate call',
});
return { success: false, error: error.message };
}
}
/**
* Accept incoming call
*/
@SubscribeMessage('call:accept')
async handleAcceptCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string },
) {
try {
this.logger.log(`User ${client.userId} accepting call ${data.callSid}`);
await this.voiceService.acceptCall({
callSid: data.callSid,
tenantId: client.tenantId,
userId: client.userId,
});
this.activeCallsByUser.set(client.userId, data.callSid);
client.emit('call:accepted', { callSid: data.callSid });
return { success: true };
} catch (error) {
this.logger.error('Failed to accept call', error);
return { success: false, error: error.message };
}
}
/**
* Reject incoming call
*/
@SubscribeMessage('call:reject')
async handleRejectCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string },
) {
try {
this.logger.log(`User ${client.userId} rejecting call ${data.callSid}`);
await this.voiceService.rejectCall(data.callSid, client.tenantId);
client.emit('call:rejected', { callSid: data.callSid });
return { success: true };
} catch (error) {
this.logger.error('Failed to reject call', error);
return { success: false, error: error.message };
}
}
/**
* End active call
*/
@SubscribeMessage('call:end')
async handleEndCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string },
) {
try {
this.logger.log(`User ${client.userId} ending call ${data.callSid}`);
await this.voiceService.endCall(data.callSid, client.tenantId);
this.activeCallsByUser.delete(client.userId);
client.emit('call:ended', { callSid: data.callSid });
return { success: true };
} catch (error) {
this.logger.error('Failed to end call', error);
return { success: false, error: error.message };
}
}
/**
* Send DTMF tones
*/
@SubscribeMessage('call:dtmf')
async handleDtmf(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string; digit: string },
) {
try {
await this.voiceService.sendDtmf(
data.callSid,
data.digit,
client.tenantId,
);
return { success: true };
} catch (error) {
this.logger.error('Failed to send DTMF', error);
return { success: false, error: error.message };
}
}
/**
* Emit incoming call notification to specific user
*/
async notifyIncomingCall(userId: string, callData: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('call:incoming', callData);
this.logger.log(`Notified user ${userId} of incoming call`);
} else {
this.logger.warn(`User ${userId} not connected to receive call notification`);
}
}
/**
* Emit call status update to user
*/
async notifyCallUpdate(userId: string, callData: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('call:update', callData);
}
}
/**
* Emit AI transcript to user
*/
async notifyAiTranscript(userId: string, data: { callSid: string; transcript: string; isFinal: boolean }) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('ai:transcript', data);
}
}
/**
* Emit AI suggestion to user
*/
async notifyAiSuggestion(userId: string, data: any) {
const socket = this.connectedUsers.get(userId);
this.logger.log(`notifyAiSuggestion - userId: ${userId}, socket connected: ${!!socket}, total connected users: ${this.connectedUsers.size}`);
if (socket) {
this.logger.log(`Emitting ai:suggestion event with data:`, JSON.stringify(data));
socket.emit('ai:suggestion', data);
} else {
this.logger.warn(`No socket connection found for userId: ${userId}`);
this.logger.log(`Connected users: ${Array.from(this.connectedUsers.keys()).join(', ')}`);
}
}
/**
* Emit AI action result to user
*/
async notifyAiAction(userId: string, data: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('ai:action', data);
}
}
/**
* Get connected users for a tenant
*/
getConnectedUsers(tenantDomain?: string): string[] {
const userIds: string[] = [];
for (const [userId, socket] of this.connectedUsers.entries()) {
// If tenantDomain specified, filter by tenant
if (!tenantDomain || socket.tenantSlug === tenantDomain) {
userIds.push(userId);
}
}
return userIds;
}
}

View File

@@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { VoiceGateway } from './voice.gateway';
import { VoiceService } from './voice.service';
import { VoiceController } from './voice.controller';
import { AudioConverterService } from './audio-converter.service';
import { TenantModule } from '../tenant/tenant.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TenantModule,
AuthModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-jwt-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '24h' },
}),
],
providers: [VoiceGateway, VoiceService, AudioConverterService],
controllers: [VoiceController],
exports: [VoiceService],
})
export class VoiceModule {}

View File

@@ -1,826 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import { IntegrationsConfig, TwilioConfig, OpenAIConfig } from './interfaces/integration-config.interface';
import * as Twilio from 'twilio';
import { WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
const AccessToken = Twilio.jwt.AccessToken;
const VoiceGrant = AccessToken.VoiceGrant;
@Injectable()
export class VoiceService {
private readonly logger = new Logger(VoiceService.name);
private twilioClients: Map<string, Twilio.Twilio> = new Map();
private openaiConnections: Map<string, WebSocket> = new Map(); // callSid -> WebSocket
private callStates: Map<string, any> = new Map(); // callSid -> call state
private voiceGateway: any; // Reference to gateway (to avoid circular dependency)
constructor(
private readonly tenantDbService: TenantDatabaseService,
) {}
/**
* Set gateway reference (called by gateway on init)
*/
setGateway(gateway: any) {
this.voiceGateway = gateway;
}
/**
* Get Twilio client for a tenant
*/
private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
// Check cache first
if (this.twilioClients.has(tenantIdOrDomain)) {
const centralPrisma = getCentralPrisma();
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
return {
client: this.twilioClients.get(tenantIdOrDomain),
config: config.twilio,
tenantId: domainRecord.tenant.id
};
}
// Fetch tenant integrations config
const centralPrisma = getCentralPrisma();
this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`);
if (!domainRecord?.tenant) {
throw new Error(`Domain ${tenantIdOrDomain} not found`);
}
if (!domainRecord.tenant.integrationsConfig) {
throw new Error('Tenant integrations config not found. Please configure Twilio credentials in Settings > Integrations');
}
const config = this.getIntegrationConfig(domainRecord.tenant.integrationsConfig as any);
this.logger.log(`Config decrypted: ${!!config.twilio}, AccountSid: ${config.twilio?.accountSid?.substring(0, 10)}..., AuthToken: ${config.twilio?.authToken?.substring(0, 10)}..., Phone: ${config.twilio?.phoneNumber}`);
if (!config.twilio?.accountSid || !config.twilio?.authToken) {
throw new Error('Twilio credentials not configured for tenant');
}
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
this.twilioClients.set(tenantIdOrDomain, client);
return { client, config: config.twilio, tenantId: domainRecord.tenant.id };
}
/**
* Decrypt and parse integrations config
*/
private getIntegrationConfig(encryptedConfig: any): IntegrationsConfig {
if (!encryptedConfig) {
return {};
}
// If it's already decrypted (object), return it
if (typeof encryptedConfig === 'object' && encryptedConfig.twilio) {
return encryptedConfig;
}
// If it's encrypted (string), decrypt it
if (typeof encryptedConfig === 'string') {
return this.tenantDbService.decryptIntegrationsConfig(encryptedConfig);
}
return {};
}
/**
* Generate Twilio access token for browser Voice SDK
*/
async generateAccessToken(tenantDomain: string, userId: string): Promise<string> {
const { config, tenantId } = await this.getTwilioClient(tenantDomain);
if (!config.accountSid || !config.apiKey || !config.apiSecret) {
throw new Error('Twilio API credentials not configured. Please add API Key and Secret in Settings > Integrations');
}
// Create an access token
const token = new AccessToken(
config.accountSid,
config.apiKey,
config.apiSecret,
{ identity: userId, ttl: 3600 } // 1 hour expiry
);
// Create a Voice grant
const voiceGrant = new VoiceGrant({
outgoingApplicationSid: config.twimlAppSid, // TwiML App SID for outbound calls
incomingAllow: true, // Allow incoming calls
});
token.addGrant(voiceGrant);
return token.toJwt();
}
/**
* Initiate outbound call
*/
async initiateCall(params: {
tenantId: string;
userId: string;
toNumber: string;
}) {
const { tenantId: tenantDomain, userId, toNumber } = params;
try {
this.logger.log(`=== INITIATING CALL ===`);
this.logger.log(`Domain: ${tenantDomain}, To: ${toNumber}, User: ${userId}`);
// Validate phone number
if (!toNumber.match(/^\+?[1-9]\d{1,14}$/)) {
throw new Error(`Invalid phone number format: ${toNumber}. Use E.164 format (e.g., +1234567890)`);
}
const { client, config, tenantId } = await this.getTwilioClient(tenantDomain);
this.logger.log(`Twilio client obtained for tenant: ${tenantId}`);
// Get from number
const fromNumber = config.phoneNumber;
if (!fromNumber) {
throw new Error('Twilio phone number not configured');
}
this.logger.log(`From number: ${fromNumber}`);
// Construct tenant-specific webhook URLs using HTTPS (for Traefik)
const backendUrl = `https://${tenantDomain}`;
const twimlUrl = `${backendUrl}/api/voice/twiml/outbound?phoneNumber=${encodeURIComponent(fromNumber)}&toNumber=${encodeURIComponent(toNumber)}`;
const statusUrl = `${backendUrl}/api/voice/webhook/status`;
this.logger.log(`TwiML URL: ${twimlUrl}`);
this.logger.log(`Status URL: ${statusUrl}`);
// Create call record in database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const callId = uuidv4();
// Initiate call via Twilio
this.logger.log(`Calling Twilio API...`);
// For Device-to-Number calls, we need to use a TwiML App SID
// The Twilio SDK will handle the Device connection, and we return TwiML with Dial
const call = await client.calls.create({
to: toNumber,
from: fromNumber, // Your Twilio phone number
url: twimlUrl,
statusCallback: statusUrl,
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
statusCallbackMethod: 'POST',
record: false,
machineDetection: 'Enable', // Optional: detect answering machines
});
this.logger.log(`Call created successfully: ${call.sid}, Status: ${call.status}`);
// Store call in database
await tenantKnex('calls').insert({
id: callId,
call_sid: call.sid,
direction: 'outbound',
from_number: fromNumber,
to_number: toNumber,
status: 'queued',
user_id: userId,
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Store call state in memory
this.callStates.set(call.sid, {
callId,
callSid: call.sid,
tenantId,
userId,
direction: 'outbound',
status: 'queued',
});
this.logger.log(`Outbound call initiated: ${call.sid}`);
return {
callId,
callSid: call.sid,
status: 'queued',
};
} catch (error) {
this.logger.error('Failed to initiate call', error);
throw error;
}
}
/**
* Accept incoming call
*/
async acceptCall(params: {
callSid: string;
tenantId: string;
userId: string;
}) {
const { callSid, tenantId, userId } = params;
try {
// Note: Twilio doesn't support updating call to 'in-progress' via API
// Call status is managed by TwiML and call flow
// We'll update our database status instead
// Update database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
status: 'in-progress',
user_id: userId,
started_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Update state
const state = this.callStates.get(callSid) || {};
this.callStates.set(callSid, {
...state,
status: 'in-progress',
userId,
});
this.logger.log(`Call accepted: ${callSid} by user ${userId}`);
} catch (error) {
this.logger.error('Failed to accept call', error);
throw error;
}
}
/**
* Reject incoming call
*/
async rejectCall(callSid: string, tenantId: string) {
try {
const { client } = await this.getTwilioClient(tenantId);
// End the call
await client.calls(callSid).update({
status: 'completed',
});
// Update database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
status: 'canceled',
updated_at: tenantKnex.fn.now(),
});
// Clean up state
this.callStates.delete(callSid);
this.logger.log(`Call rejected: ${callSid}`);
} catch (error) {
this.logger.error('Failed to reject call', error);
throw error;
}
}
/**
* End active call
*/
async endCall(callSid: string, tenantId: string) {
try {
const { client } = await this.getTwilioClient(tenantId);
// End the call
await client.calls(callSid).update({
status: 'completed',
});
// Clean up OpenAI connection if exists
const openaiWs = this.openaiConnections.get(callSid);
if (openaiWs) {
openaiWs.close();
this.openaiConnections.delete(callSid);
}
// Update database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
status: 'completed',
ended_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Clean up state
this.callStates.delete(callSid);
this.logger.log(`Call ended: ${callSid}`);
} catch (error) {
this.logger.error('Failed to end call', error);
throw error;
}
}
/**
* Send DTMF tones
*/
async sendDtmf(callSid: string, digit: string, tenantId: string) {
try {
const { client } = await this.getTwilioClient(tenantId);
// Twilio doesn't support sending DTMF directly via API
// This would need to be handled via TwiML <Play> of DTMF tones
this.logger.log(`DTMF requested for call ${callSid}: ${digit}`);
// TODO: Implement DTMF sending via TwiML update
} catch (error) {
this.logger.error('Failed to send DTMF', error);
throw error;
}
}
/**
* Get call state
*/
async getCallState(callSid: string, tenantId: string) {
// Try memory first
if (this.callStates.has(callSid)) {
return this.callStates.get(callSid);
}
// Fallback to database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const call = await tenantKnex('calls')
.where({ call_sid: callSid })
.first();
return call || null;
}
/**
* Update call status from webhook
*/
async updateCallStatus(params: {
callSid: string;
tenantId: string;
status: string;
duration?: number;
recordingUrl?: string;
}) {
const { callSid, tenantId, status, duration, recordingUrl } = params;
try {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const updateData: any = {
status,
updated_at: tenantKnex.fn.now(),
};
if (duration !== undefined) {
updateData.duration_seconds = duration;
}
if (recordingUrl) {
updateData.recording_url = recordingUrl;
}
if (status === 'completed') {
updateData.ended_at = tenantKnex.fn.now();
}
await tenantKnex('calls')
.where({ call_sid: callSid })
.update(updateData);
// Update state
const state = this.callStates.get(callSid);
if (state) {
this.callStates.set(callSid, { ...state, status });
}
this.logger.log(`Call status updated: ${callSid} -> ${status}`);
} catch (error) {
this.logger.error('Failed to update call status', error);
throw error;
}
}
/**
* Initialize OpenAI Realtime connection for call
*/
async initializeOpenAIRealtime(params: {
callSid: string;
tenantId: string;
userId: string;
}) {
const { callSid, tenantId, userId } = params;
try {
// Get OpenAI config - tenantId might be a domain, so look it up
const centralPrisma = getCentralPrisma();
// Try to find tenant by domain first (if tenantId is like "tenant1")
let tenant;
if (!tenantId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-/i)) {
// Looks like a domain, not a UUID
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantId },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
tenant = domainRecord?.tenant;
} else {
// It's a UUID
tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, integrationsConfig: true },
});
}
if (!tenant) {
this.logger.warn(`Tenant not found for identifier: ${tenantId}`);
return;
}
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
if (!config.openai?.apiKey) {
this.logger.warn('OpenAI not configured for tenant, skipping AI features');
return;
}
// Connect to OpenAI Realtime API
const model = config.openai.model || 'gpt-4o-realtime-preview-2024-10-01';
const ws = new WebSocket(`wss://api.openai.com/v1/realtime?model=${model}`, {
headers: {
'Authorization': `Bearer ${config.openai.apiKey}`,
'OpenAI-Beta': 'realtime=v1',
},
});
ws.on('open', () => {
this.logger.log(`OpenAI Realtime connected for call ${callSid}`);
// Add to connections map only after it's open
this.openaiConnections.set(callSid, ws);
// Store call state with userId for later use
this.callStates.set(callSid, {
callSid,
tenantId: tenant.id,
userId,
status: 'in-progress',
});
this.logger.log(`📝 Stored call state for ${callSid} with userId: ${userId}`);
// Initialize session
ws.send(JSON.stringify({
type: 'session.update',
session: {
model: config.openai.model || 'gpt-4o-realtime-preview',
voice: config.openai.voice || 'alloy',
instructions: `You are an AI assistant in LISTENING MODE, helping a sales/support agent during their phone call.
IMPORTANT: You are NOT talking to the caller. You are advising the agent who is handling the call.
Your role:
- Listen to the conversation between the agent and the caller
- Provide concise, actionable suggestions to help the agent
- Recommend CRM actions (search contacts, create tasks, update records)
- Alert the agent to important information or next steps
- Keep suggestions brief (1-2 sentences max)
Format your suggestions like:
"💡 Suggestion: [your advice]"
"⚠️ Alert: [important notice]"
"📋 Action: [recommended CRM action]"`,
turn_detection: {
type: 'server_vad',
},
tools: this.getOpenAITools(),
},
}));
});
ws.on('message', (data: Buffer) => {
// Pass the tenant UUID (tenant.id) instead of the domain string
this.handleOpenAIMessage(callSid, tenant.id, userId, JSON.parse(data.toString()));
});
ws.on('error', (error) => {
this.logger.error(`OpenAI WebSocket error for call ${callSid}:`, error);
this.openaiConnections.delete(callSid);
});
ws.on('close', (code, reason) => {
this.logger.log(`OpenAI Realtime disconnected for call ${callSid} - Code: ${code}, Reason: ${reason.toString()}`);
this.openaiConnections.delete(callSid);
});
// Don't add to connections here - wait for 'open' event
} catch (error) {
this.logger.error('Failed to initialize OpenAI Realtime', error);
}
}
/**
* Send audio data to OpenAI Realtime API
*/
async sendAudioToOpenAI(callSid: string, audioBase64: string) {
const ws = this.openaiConnections.get(callSid);
if (!ws) {
this.logger.warn(`No OpenAI connection for call ${callSid}`);
return;
}
try {
// Send audio chunk to OpenAI
ws.send(JSON.stringify({
type: 'input_audio_buffer.append',
audio: audioBase64,
}));
} catch (error) {
this.logger.error(`Failed to send audio to OpenAI for call ${callSid}`, error);
}
}
/**
* Commit audio buffer to OpenAI (trigger processing)
*/
async commitAudioBuffer(callSid: string) {
const ws = this.openaiConnections.get(callSid);
if (!ws) {
return;
}
try {
ws.send(JSON.stringify({
type: 'input_audio_buffer.commit',
}));
} catch (error) {
this.logger.error(`Failed to commit audio buffer for call ${callSid}`, error);
}
}
/**
* Clean up OpenAI connection for a call
*/
async cleanupOpenAIConnection(callSid: string) {
const ws = this.openaiConnections.get(callSid);
if (ws) {
try {
ws.close();
this.openaiConnections.delete(callSid);
this.logger.log(`Cleaned up OpenAI connection for call ${callSid}`);
} catch (error) {
this.logger.error(`Error cleaning up OpenAI connection for call ${callSid}`, error);
}
}
}
/**
* Handle OpenAI Realtime messages
*/
private async handleOpenAIMessage(
callSid: string,
tenantId: string,
userId: string,
message: any,
) {
try {
switch (message.type) {
case 'conversation.item.created':
// Skip logging for now
break;
case 'response.audio.delta':
// OpenAI is sending audio response (skip logging)
const state = this.callStates.get(callSid);
if (state?.streamSid && message.delta) {
if (!state.pendingAudio) {
state.pendingAudio = [];
}
state.pendingAudio.push(message.delta);
}
break;
case 'response.audio.done':
// Skip logging
break;
case 'response.audio_transcript.delta':
// Skip - not transmitting individual words to frontend
break;
case 'response.audio_transcript.done':
// Final transcript - this contains the AI's actual text suggestions!
const transcript = message.transcript;
this.logger.log(`💡 AI Suggestion: "${transcript}"`);
// Save to database
await this.updateCallTranscript(callSid, tenantId, transcript);
// Also send as suggestion to frontend if it looks like a suggestion
if (transcript && transcript.length > 0) {
// Determine suggestion type
let suggestionType: 'response' | 'action' | 'insight' = 'insight';
if (transcript.includes('💡') || transcript.toLowerCase().includes('suggest')) {
suggestionType = 'response';
} else if (transcript.includes('📋') || transcript.toLowerCase().includes('action')) {
suggestionType = 'action';
} else if (transcript.includes('⚠️') || transcript.toLowerCase().includes('alert')) {
suggestionType = 'insight';
}
// Emit to frontend
const state = this.callStates.get(callSid);
this.logger.log(`📊 Call state - userId: ${state?.userId}, gateway: ${!!this.voiceGateway}`);
if (state?.userId && this.voiceGateway) {
this.logger.log(`📤 Sending to user ${state.userId}`);
await this.voiceGateway.notifyAiSuggestion(state.userId, {
type: suggestionType,
text: transcript,
callSid,
timestamp: new Date().toISOString(),
});
this.logger.log(`✅ Suggestion sent to agent`);
} else {
this.logger.warn(`❌ Cannot send - userId: ${state?.userId}, gateway: ${!!this.voiceGateway}, callStates has ${this.callStates.size} entries`);
}
}
break;
case 'response.function_call_arguments.done':
// Tool call completed
await this.handleToolCall(callSid, tenantId, userId, message);
break;
case 'session.created':
case 'session.updated':
case 'response.created':
case 'response.output_item.added':
case 'response.content_part.added':
case 'response.content_part.done':
case 'response.output_item.done':
case 'response.done':
case 'input_audio_buffer.speech_started':
case 'input_audio_buffer.speech_stopped':
case 'input_audio_buffer.committed':
// Skip logging for these (too noisy)
break;
case 'error':
this.logger.error(`OpenAI error for call ${callSid}: ${JSON.stringify(message.error)}`);
break;
default:
// Only log unhandled types occasionally
break;
}
} catch (error) {
this.logger.error('Failed to handle OpenAI message', error);
}
}
/**
* Define OpenAI tools for CRM actions
*/
private getOpenAITools(): any[] {
return [
{
type: 'function',
name: 'search_contact',
description: 'Search for a contact by name, email, or phone number',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (name, email, or phone)',
},
},
required: ['query'],
},
},
{
type: 'function',
name: 'create_task',
description: 'Create a follow-up task based on the call',
parameters: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Task title',
},
description: {
type: 'string',
description: 'Task description',
},
dueDate: {
type: 'string',
description: 'Due date (ISO format)',
},
},
required: ['title'],
},
},
{
type: 'function',
name: 'update_contact',
description: 'Update contact information',
parameters: {
type: 'object',
properties: {
contactId: {
type: 'string',
description: 'Contact ID',
},
fields: {
type: 'object',
description: 'Fields to update',
},
},
required: ['contactId', 'fields'],
},
},
];
}
/**
* Handle tool calls from OpenAI
*/
private async handleToolCall(
callSid: string,
tenantId: string,
userId: string,
message: any,
) {
// TODO: Implement actual tool execution
// This would call the appropriate services based on the tool name
// Respecting RBAC permissions for the user
this.logger.log(`Tool call for call ${callSid}: ${message.name}`);
}
/**
* Update call transcript
*/
private async updateCallTranscript(
callSid: string,
tenantId: string,
transcript: string,
) {
try {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
ai_transcript: transcript,
updated_at: tenantKnex.fn.now(),
});
} catch (error) {
this.logger.error('Failed to update transcript', error);
}
}
/**
* Get call history for user
*/
async getCallHistory(tenantId: string, userId: string, limit = 50) {
try {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const calls = await tenantKnex('calls')
.where({ user_id: userId })
.orderBy('created_at', 'desc')
.limit(limit);
return calls;
} catch (error) {
this.logger.error('Failed to get call history', error);
throw error;
}
}
}

View File

@@ -1,211 +0,0 @@
# Salesforce-Style Authorization System
## Overview
Implemented a comprehensive authorization system based on Salesforce's model with:
- **Org-Wide Defaults (OWD)** for record visibility
- **Role-based permissions** for object and field access
- **Record sharing** for granular access control
- **CASL** for flexible permission evaluation
## Architecture
### 1. Org-Wide Defaults (OWD)
Controls baseline record visibility for each object:
- `private`: Only owner can see records
- `public_read`: Everyone can see, only owner can edit/delete
- `public_read_write`: Everyone can see and modify all records
### 2. Role-Based Object Permissions
Table: `role_object_permissions`
- `canCreate`: Can create new records
- `canRead`: Can read records (subject to OWD)
- `canEdit`: Can edit records (subject to OWD)
- `canDelete`: Can delete records (subject to OWD)
- `canViewAll`: Override OWD to see ALL records
- `canModifyAll`: Override OWD to edit ALL records
### 3. Field-Level Security
Table: `role_field_permissions`
- `canRead`: Can view field value
- `canEdit`: Can modify field value
### 4. Record Sharing
Table: `record_shares`
Grants specific users access to individual records with:
```json
{
"canRead": boolean,
"canEdit": boolean,
"canDelete": boolean
}
```
## Permission Evaluation Flow
```
1. Check role_object_permissions
├─ Does user have canCreate/Read/Edit/Delete?
│ └─ NO → Deny
│ └─ YES → Continue
2. Check canViewAll / canModifyAll
├─ Does user have special "all" permissions?
│ └─ YES → Grant access
│ └─ NO → Continue
3. Check OWD (orgWideDefault)
├─ public_read_write → Grant access
├─ public_read → Grant read, check ownership for write
└─ private → Check ownership or sharing
4. Check Ownership
├─ Is user the record owner?
│ └─ YES → Grant access
│ └─ NO → Continue
5. Check Record Shares
└─ Is record explicitly shared with user?
└─ Check accessLevel permissions
```
## Field-Level Security
Fields are filtered after record access is granted:
1. User queries records → Apply record-level scope
2. System filters readable fields based on `role_field_permissions`
3. User updates records → System filters editable fields
## Key Features
### Multiple Role Support
- Users can have multiple roles
- Permissions are **unioned** (any role grants = user has it)
- More flexible than Salesforce's single profile model
### Active Share Detection
- Shares can expire (`expiresAt`)
- Shares can be revoked (`revokedAt`)
- Only active shares are evaluated
### CASL Integration
- Dynamic ability building per request
- Condition-based rules
- Field-level permission support
## Usage Example
```typescript
// In a controller/service
constructor(
private authService: AuthorizationService,
private tenantDbService: TenantDatabaseService,
) {}
async getRecords(tenantId: string, objectApiName: string, userId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
// Get user with roles
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
// Get object definition
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
// Build query with authorization scope
let query = knex(objectApiName.toLowerCase());
query = await this.authService.applyScopeToQuery(
query,
objectDef,
user,
'read',
knex,
);
const records = await query;
// Get field definitions
const fields = await FieldDefinition.query(knex)
.where('objectDefinitionId', objectDef.id);
// Filter fields user can read
const filteredRecords = await Promise.all(
records.map(record =>
this.authService.filterReadableFields(record, fields, user)
)
);
return filteredRecords;
}
async updateRecord(tenantId: string, objectApiName: string, recordId: string, data: any, userId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
const objectDef = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
// Get existing record
const record = await knex(objectApiName.toLowerCase())
.where({ id: recordId })
.first();
if (!record) {
throw new NotFoundException('Record not found');
}
// Check if user can update this record
await this.authService.assertCanPerformAction(
'update',
objectDef,
record,
user,
knex,
);
// Get field definitions
const fields = await FieldDefinition.query(knex)
.where('objectDefinitionId', objectDef.id);
// Filter to only editable fields
const editableData = await this.authService.filterEditableFields(
data,
fields,
user,
);
// Perform update
await knex(objectApiName.toLowerCase())
.where({ id: recordId })
.update(editableData);
return knex(objectApiName.toLowerCase())
.where({ id: recordId })
.first();
}
```
## Migration
Run the migration to add authorization tables:
```bash
npm run knex migrate:latest
```
The migration creates:
- `orgWideDefault` column in `object_definitions`
- `role_object_permissions` table
- `role_field_permissions` table
- `record_shares` table
## Next Steps
1. **Migrate existing data**: Set default `orgWideDefault` values for existing objects
2. **Create default roles**: Create Admin, Standard User, etc. with appropriate permissions
3. **Update API endpoints**: Integrate authorization service into all CRUD operations
4. **UI for permission management**: Build admin interface to manage role permissions
5. **Sharing UI**: Build interface for users to share records with others

View File

@@ -1,219 +0,0 @@
# Softphone Configuration Checklist
## Pre-Deployment Checklist
### Backend Configuration
- [ ] **Environment Variables Set**
- [ ] `BACKEND_URL` - Public URL of backend (e.g., `https://api.yourdomain.com`)
- [ ] `ENCRYPTION_KEY` - 32-byte hex key for encrypting credentials
- [ ] Database connection URLs configured
- [ ] **Dependencies Installed**
```bash
cd backend
npm install
```
- [ ] **Migrations Run**
```bash
# Generate Prisma client
npx prisma generate --schema=./prisma/schema-central.prisma
# Run tenant migrations (creates calls table)
npm run migrate:all-tenants
```
- [ ] **Build Succeeds**
```bash
npm run build
```
### Frontend Configuration
- [ ] **Environment Variables Set**
- [ ] `VITE_BACKEND_URL` - Backend URL (e.g., `https://api.yourdomain.com`)
- [ ] **Dependencies Installed**
```bash
cd frontend
npm install
```
- [ ] **Build Succeeds**
```bash
npm run build
```
### Twilio Setup
- [ ] **Account Created**
- [ ] Sign up at https://www.twilio.com
- [ ] Verify account (phone/email)
- [ ] **Credentials Retrieved**
- [ ] Account SID (starts with `AC...`)
- [ ] Auth Token (from Twilio Console)
- [ ] **Phone Number Purchased**
- [ ] Buy a phone number in Twilio Console
- [ ] Note the phone number in E.164 format (e.g., `+1234567890`)
- [ ] **Webhooks Configured**
- [ ] Go to Phone Numbers → Active Numbers → [Your Number]
- [ ] Voice Configuration:
- [ ] A CALL COMES IN: Webhook
- [ ] URL: `https://your-backend-url.com/api/voice/twiml/inbound`
- [ ] HTTP: POST
- [ ] Status Callback:
- [ ] URL: `https://your-backend-url.com/api/voice/webhook/status`
- [ ] HTTP: POST
- [ ] **Media Streams (Optional)**
- [ ] Enable Media Streams in Twilio Console
- [ ] Note: Full implementation pending
### OpenAI Setup (Optional)
- [ ] **API Key Obtained**
- [ ] Sign up at https://platform.openai.com
- [ ] Create API key in API Keys section
- [ ] Copy key (starts with `sk-...`)
- [ ] **Realtime API Access**
- [ ] Ensure account has access to Realtime API (beta feature)
- [ ] Contact OpenAI support if needed
- [ ] **Model & Voice Selected**
- [ ] Model: `gpt-4o-realtime-preview` (default)
- [ ] Voice: `alloy`, `echo`, `fable`, `onyx`, `nova`, or `shimmer`
### Tenant Configuration
- [ ] **Log into Tenant**
- [ ] Use tenant subdomain (e.g., `acme.yourdomain.com`)
- [ ] Login with tenant user account
- [ ] **Navigate to Integrations**
- [ ] Go to Settings → Integrations (create page if doesn't exist)
- [ ] **Configure Twilio**
- [ ] Enter Account SID
- [ ] Enter Auth Token
- [ ] Enter Phone Number (with country code)
- [ ] Click Save Configuration
- [ ] **Configure OpenAI (Optional)**
- [ ] Enter API Key
- [ ] Set Model (or use default)
- [ ] Set Voice (or use default)
- [ ] Click Save Configuration
### Testing
- [ ] **WebSocket Connection**
- [ ] Open browser DevTools → Network → WS
- [ ] Click "Softphone" button in sidebar
- [ ] Verify WebSocket connection to `/voice` namespace
- [ ] Check for "Connected" status in softphone dialog
- [ ] **Outbound Call**
- [ ] Enter a test phone number
- [ ] Click "Call"
- [ ] Verify call initiates
- [ ] Check call appears in Twilio Console → Logs
- [ ] Verify call status updates in UI
- [ ] **Inbound Call**
- [ ] Call your Twilio number from external phone
- [ ] Verify incoming call notification appears
- [ ] Verify ringtone plays
- [ ] Click "Accept"
- [ ] Verify call connects
- [ ] **AI Features (if OpenAI configured)**
- [ ] Make a call
- [ ] Speak during call
- [ ] Verify transcript appears in real-time
- [ ] Check for AI suggestions
- [ ] Test AI tool calls (if configured)
- [ ] **Call History**
- [ ] Make/receive multiple calls
- [ ] Open softphone dialog
- [ ] Verify recent calls appear
- [ ] Click recent call to redial
### Production Readiness
- [ ] **Security**
- [ ] HTTPS enabled on backend
- [ ] WSS (WebSocket Secure) working
- [ ] CORS configured correctly
- [ ] Environment variables secured
- [ ] **Monitoring**
- [ ] Backend logs accessible
- [ ] Error tracking setup (e.g., Sentry)
- [ ] Twilio logs monitored
- [ ] **Scalability**
- [ ] Redis configured for BullMQ (future)
- [ ] Database connection pooling configured
- [ ] Load balancer if needed
- [ ] **Documentation**
- [ ] User guide shared with team
- [ ] Twilio credentials documented securely
- [ ] Support process defined
## Verification Commands
```bash
# Check backend build
cd backend && npm run build
# Check frontend build
cd frontend && npm run build
# Verify migrations
cd backend && npm run migrate:status
# Test WebSocket (after starting backend)
# In browser console:
const socket = io('http://localhost:3000/voice', {
auth: { token: 'YOUR_JWT_TOKEN' }
});
socket.on('connect', () => console.log('Connected!'));
```
## Common Issues & Solutions
| Issue | Check | Solution |
|-------|-------|----------|
| "Not connected" | WebSocket URL | Verify BACKEND_URL in frontend .env |
| Build fails | Dependencies | Run `npm install` again |
| Twilio errors | Credentials | Re-enter credentials in settings |
| No AI features | OpenAI key | Add API key in integrations |
| Webhook 404 | URL format | Ensure `/api/voice/...` prefix |
| HTTPS required | Twilio webhooks | Deploy with HTTPS or use ngrok for testing |
## Post-Deployment Tasks
- [ ] Train users on softphone features
- [ ] Monitor call quality and errors
- [ ] Collect feedback for improvements
- [ ] Plan for scaling (queue system, routing)
- [ ] Review call logs for insights
## Support Resources
- **Twilio Docs**: https://www.twilio.com/docs
- **OpenAI Realtime API**: https://platform.openai.com/docs/guides/realtime
- **Project Docs**: `/docs/SOFTPHONE_IMPLEMENTATION.md`
- **Quick Start**: `/docs/SOFTPHONE_QUICK_START.md`
---
**Last Updated**: January 3, 2026
**Checklist Version**: 1.0

View File

@@ -1,370 +0,0 @@
# Softphone Implementation with Twilio & OpenAI Realtime
## Overview
This implementation adds comprehensive voice calling functionality to the platform using Twilio for telephony and OpenAI Realtime API for AI-assisted calls. The softphone is accessible globally through a Vue component, with call state managed via WebSocket connections.
## Architecture
### Backend (NestJS + Fastify)
#### Core Components
1. **VoiceModule** (`backend/src/voice/`)
- `voice.module.ts` - Module configuration
- `voice.gateway.ts` - WebSocket gateway for real-time signaling
- `voice.service.ts` - Business logic for call orchestration
- `voice.controller.ts` - REST endpoints and Twilio webhooks
- `dto/` - Data transfer objects for type safety
- `interfaces/` - TypeScript interfaces for configuration
2. **Database Schema**
- **Central Database**: `integrationsConfig` JSON field in Tenant model (encrypted)
- **Tenant Database**: `calls` table for call history and metadata
3. **WebSocket Gateway**
- Namespace: `/voice`
- Authentication: JWT token validation in handshake
- Tenant Context: Extracted from JWT payload
- Events: `call:initiate`, `call:accept`, `call:reject`, `call:end`, `call:dtmf`
- AI Events: `ai:transcript`, `ai:suggestion`, `ai:action`
4. **Twilio Integration**
- SDK: `twilio` npm package
- Features: Outbound calls, TwiML responses, Media Streams, webhooks
- Credentials: Stored encrypted per tenant in `integrationsConfig.twilio`
5. **OpenAI Realtime Integration**
- Connection: WebSocket to `wss://api.openai.com/v1/realtime`
- Features: Real-time transcription, AI suggestions, tool calling
- Credentials: Stored encrypted per tenant in `integrationsConfig.openai`
### Frontend (Nuxt 3 + Vue 3)
#### Core Components
1. **useSoftphone Composable** (`frontend/composables/useSoftphone.ts`)
- Module-level shared state for global access
- WebSocket connection management with auto-reconnect
- Call state management (current call, incoming call)
- Audio management (ringtone playback)
- Event handlers for call lifecycle and AI events
2. **SoftphoneDialog Component** (`frontend/components/SoftphoneDialog.vue`)
- Global dialog accessible from anywhere
- Features:
- Dialer with numeric keypad
- Incoming call notifications with ringtone
- Active call controls (mute, DTMF, hang up)
- Real-time transcript display
- AI suggestions panel
- Recent call history
3. **Integration in Layout** (`frontend/layouts/default.vue`)
- SoftphoneDialog included globally
- Sidebar button with incoming call indicator
4. **Settings Page** (`frontend/pages/settings/integrations.vue`)
- Configure Twilio credentials
- Configure OpenAI API settings
- Encrypted storage via backend API
## Configuration
### Environment Variables
#### Backend (.env)
```env
BACKEND_URL=http://localhost:3000
ENCRYPTION_KEY=your-32-byte-hex-key
```
#### Frontend (.env)
```env
VITE_BACKEND_URL=http://localhost:3000
```
### Tenant Configuration
Integrations are configured per tenant via the settings UI or API:
```json
{
"twilio": {
"accountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"authToken": "your-auth-token",
"phoneNumber": "+1234567890"
},
"openai": {
"apiKey": "sk-...",
"model": "gpt-4o-realtime-preview",
"voice": "alloy"
}
}
```
This configuration is encrypted using AES-256-CBC and stored in the central database.
## API Endpoints
### REST Endpoints
- `POST /api/voice/call` - Initiate outbound call
- `GET /api/voice/calls` - Get call history
- `POST /api/voice/twiml/outbound` - TwiML for outbound calls
- `POST /api/voice/twiml/inbound` - TwiML for inbound calls
- `POST /api/voice/webhook/status` - Twilio status webhook
- `POST /api/voice/webhook/recording` - Twilio recording webhook
- `GET /api/tenant/integrations` - Get integrations config (masked)
- `PUT /api/tenant/integrations` - Update integrations config
### WebSocket Events
#### Client → Server
- `call:initiate` - Initiate outbound call
- `call:accept` - Accept incoming call
- `call:reject` - Reject incoming call
- `call:end` - End active call
- `call:dtmf` - Send DTMF tone
#### Server → Client
- `call:incoming` - Incoming call notification
- `call:initiated` - Call initiation confirmed
- `call:accepted` - Call accepted
- `call:rejected` - Call rejected
- `call:ended` - Call ended
- `call:update` - Call status update
- `call:error` - Call error
- `call:state` - Full call state sync
- `ai:transcript` - AI transcription update
- `ai:suggestion` - AI suggestion
- `ai:action` - AI action executed
## Database Schema
### Central Database - Tenant Model
```prisma
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
dbHost String
dbPort Int @default(3306)
dbName String
dbUsername String
dbPassword String // Encrypted
integrationsConfig Json? // NEW: Encrypted JSON config
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
domains Domain[]
}
```
### Tenant Database - Calls Table
```sql
CREATE TABLE calls (
id VARCHAR(36) PRIMARY KEY,
call_sid VARCHAR(100) UNIQUE NOT NULL,
direction ENUM('inbound', 'outbound') NOT NULL,
from_number VARCHAR(20) NOT NULL,
to_number VARCHAR(20) NOT NULL,
status ENUM('queued', 'ringing', 'in-progress', 'completed', 'busy', 'failed', 'no-answer', 'canceled'),
duration_seconds INT UNSIGNED,
recording_url VARCHAR(500),
ai_transcript TEXT,
ai_summary TEXT,
ai_insights JSON,
user_id VARCHAR(36) NOT NULL,
started_at TIMESTAMP,
ended_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_call_sid (call_sid),
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_direction (direction),
INDEX idx_created_user (created_at, user_id)
);
```
## Usage
### For Developers
1. **Install Dependencies**
```bash
cd backend && npm install
cd ../frontend && npm install
```
2. **Configure Environment**
- Set `ENCRYPTION_KEY` in backend `.env`
- Ensure `BACKEND_URL` matches your deployment
3. **Run Migrations**
```bash
cd backend
# Central database migration is handled by Prisma
npm run migrate:all-tenants # Run tenant migrations
```
4. **Start Services**
```bash
# Backend
cd backend && npm run start:dev
# Frontend
cd frontend && npm run dev
```
### For Users
1. **Configure Integrations**
- Navigate to Settings → Integrations
- Enter Twilio credentials (Account SID, Auth Token, Phone Number)
- Enter OpenAI API key
- Click "Save Configuration"
2. **Make a Call**
- Click the "Softphone" button in the sidebar
- Enter a phone number (E.164 format: +1234567890)
- Click "Call"
3. **Receive Calls**
- Configure Twilio webhook URLs to point to your backend
- Incoming calls will trigger a notification and ringtone
- Click "Accept" to answer or "Reject" to decline
## Advanced Features
### AI-Assisted Calling
The OpenAI Realtime API provides:
1. **Real-time Transcription** - Live speech-to-text during calls
2. **AI Suggestions** - Contextual suggestions for agents
3. **Tool Calling** - CRM actions via AI (search contacts, create tasks, etc.)
### Tool Definitions
The system includes predefined tools for AI:
- `search_contact` - Search CRM for contacts
- `create_task` - Create follow-up tasks
- `update_contact` - Update contact information
Tools automatically respect RBAC permissions as they call existing protected services.
### Call Recording
- Automatic recording via Twilio
- Recording URLs stored in call records
- Accessible via API for playback
## Security
1. **Encryption** - All credentials encrypted using AES-256-CBC
2. **Authentication** - JWT-based auth for WebSocket and REST
3. **Tenant Isolation** - Multi-tenant architecture with database-per-tenant
4. **RBAC** - Permission-based access control (future: add voice-specific permissions)
## Limitations & Future Enhancements
### Current Limitations
1. **Media Streaming** - Twilio Media Streams WebSocket not fully implemented
2. **Call Routing** - No intelligent routing for inbound calls yet
3. **Queue Management** - Basic call handling, no queue system
4. **Audio Muting** - UI placeholder, actual audio muting not implemented
5. **RBAC Permissions** - Voice-specific permissions not yet added
### Planned Enhancements
1. **Media Streams** - Full bidirectional audio between Twilio ↔ OpenAI ↔ User
2. **Call Routing** - Route calls based on availability, skills, round-robin
3. **Queue System** - Call queuing with BullMQ integration
4. **Call Analytics** - Dashboard with call metrics and insights
5. **RBAC Integration** - Add `voice.make_calls`, `voice.receive_calls` permissions
6. **WebRTC** - Direct browser-to-Twilio audio (bypass backend)
## Troubleshooting
### WebSocket Connection Issues
- Verify `BACKEND_URL` environment variable
- Check CORS settings in backend
- Ensure JWT token is valid and includes tenant information
### Twilio Webhook Errors
- Ensure webhook URLs are publicly accessible
- Verify Twilio credentials in integrations config
- Check backend logs for webhook processing errors
### OpenAI Connection Issues
- Verify OpenAI API key has Realtime API access
- Check network connectivity to OpenAI endpoints
- Monitor backend logs for WebSocket errors
## Testing
### Manual Testing
1. **Outbound Calls**
```bash
# Open softphone dialog
# Enter test number (use Twilio test credentials)
# Click Call
# Verify call status updates
```
2. **Inbound Calls**
```bash
# Configure Twilio number webhook
# Call the Twilio number from external phone
# Verify incoming call notification
# Accept call and verify connection
```
3. **AI Features**
```bash
# Make a call with OpenAI configured
# Speak during the call
# Verify transcript appears in UI
# Check for AI suggestions
```
## Dependencies
### Backend
- `@nestjs/websockets` - WebSocket support
- `@nestjs/platform-socket.io` - Socket.IO adapter
- `@fastify/websocket` - Fastify WebSocket plugin
- `socket.io` - WebSocket library
- `twilio` - Twilio SDK
- `openai` - OpenAI SDK (for Realtime API)
- `ws` - WebSocket client
### Frontend
- `socket.io-client` - WebSocket client
- `lucide-vue-next` - Icons
- `vue-sonner` - Toast notifications
## Support
For issues or questions:
1. Check backend logs for error details
2. Verify tenant integrations configuration
3. Test Twilio/OpenAI connectivity independently
4. Review WebSocket connection in browser DevTools
## License
Same as project license.

View File

@@ -1,94 +0,0 @@
# Softphone Quick Start Guide
## Setup (5 minutes)
### 1. Configure Twilio
1. Create a Twilio account at https://www.twilio.com
2. Get your credentials:
- Account SID (starts with AC...)
- Auth Token
- Purchase a phone number
3. Configure webhook URLs in Twilio Console:
- Voice webhook: `https://your-domain.com/api/voice/twiml/inbound`
- Status callback: `https://your-domain.com/api/voice/webhook/status`
### 2. Configure OpenAI (Optional for AI features)
1. Get OpenAI API key from https://platform.openai.com
2. Ensure you have access to Realtime API (beta feature)
### 3. Add Credentials to Platform
1. Log into your tenant
2. Navigate to **Settings → Integrations**
3. Fill in Twilio section:
- Account SID
- Auth Token
- Phone Number (format: +1234567890)
4. Fill in OpenAI section (optional):
- API Key
- Model: `gpt-4o-realtime-preview` (default)
- Voice: `alloy` (default)
5. Click **Save Configuration**
## Using the Softphone
### Make a Call
1. Click **Softphone** button in sidebar (phone icon)
2. Enter phone number in E.164 format: `+1234567890`
3. Click **Call** or press Enter
4. Wait for connection
5. During call:
- Click **hash** icon for DTMF keypad
- Click **microphone** to mute/unmute
- Click **red phone** to hang up
### Receive a Call
1. Softphone automatically connects when logged in
2. Incoming call notification appears with ringtone
3. Click **Accept** (green button) or **Reject** (red button)
4. If accepted, call controls appear
### AI Features (if OpenAI configured)
- **Real-time Transcript**: See what's being said live
- **AI Suggestions**: Get contextual tips during calls
- **Smart Actions**: AI can search contacts, create tasks automatically
## Quick Tips
- ✅ Phone number format: `+1234567890` (include country code)
- ✅ Close dialog: Click outside or press Escape
- ✅ Incoming calls work even if dialog is closed
- ✅ Recent calls appear for quick redial
- ❌ Don't forget to save credentials before testing
- ❌ Webhook URLs must be publicly accessible (not localhost)
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Not connected" | Check credentials in Settings → Integrations |
| Can't make calls | Verify Twilio Account SID and Auth Token |
| Can't receive calls | Check Twilio webhook configuration |
| No AI features | Add OpenAI API key in settings |
| WebSocket errors | Check browser console, verify backend URL |
## Testing with Twilio Test Credentials
For development, Twilio provides test credentials:
- Use Twilio test numbers
- No actual calls are made
- Simulate call flows in development
## Next Steps
- 📞 Make your first test call
- 🎤 Try the AI transcription feature
- 📊 View call history in Softphone dialog
- ⚙️ Configure call routing (advanced)
Need help? Check `/docs/SOFTPHONE_IMPLEMENTATION.md` for detailed documentation.

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