Compare commits
7 Commits
codex/impl
...
52c0849de2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c0849de2 | ||
|
|
b9fa3bd008 | ||
|
|
2bc672e4c5 | ||
|
|
962c84e6d2 | ||
|
|
fc1bec4de7 | ||
|
|
0275b96014 | ||
|
|
e4f3bad971 |
5
.env.api
5
.env.api
@@ -5,11 +5,6 @@ DATABASE_URL="mysql://platform:platform@db:3306/platform"
|
|||||||
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
|
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
|
||||||
REDIS_URL="redis://redis:6379"
|
REDIS_URL="redis://redis:6379"
|
||||||
|
|
||||||
# Meilisearch (optional)
|
|
||||||
MEILI_HOST="http://meilisearch:7700"
|
|
||||||
MEILI_API_KEY="dev-meili-master-key"
|
|
||||||
MEILI_INDEX_PREFIX="tenant_"
|
|
||||||
|
|
||||||
# JWT, multi-tenant hints, etc.
|
# JWT, multi-tenant hints, etc.
|
||||||
JWT_SECRET="devsecret"
|
JWT_SECRET="devsecret"
|
||||||
TENANCY_STRATEGY="single-db"
|
TENANCY_STRATEGY="single-db"
|
||||||
|
|||||||
4
.env.web
4
.env.web
@@ -1,5 +1,5 @@
|
|||||||
NUXT_PORT=3001
|
NUXT_PORT=3001
|
||||||
NUXT_HOST=0.0.0.0
|
NUXT_HOST=0.0.0.0
|
||||||
|
|
||||||
# Nitro BFF backend URL (server-only, not exposed to client)
|
# Point Nuxt to the API container (not localhost)
|
||||||
BACKEND_URL=https://backend.routebox.co
|
NUXT_PUBLIC_API_BASE_URL=http://jupiter.routebox.co:3000
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
exports.up = function (knex) {
|
|
||||||
return knex.schema.createTable('custom_migrations', (table) => {
|
|
||||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
|
||||||
table.uuid('tenantId').notNullable();
|
|
||||||
table.string('name', 255).notNullable();
|
|
||||||
table.text('description');
|
|
||||||
table.enum('type', [
|
|
||||||
'create_table',
|
|
||||||
'add_column',
|
|
||||||
'alter_column',
|
|
||||||
'add_index',
|
|
||||||
'drop_table',
|
|
||||||
'custom',
|
|
||||||
]).notNullable();
|
|
||||||
table.text('sql').notNullable();
|
|
||||||
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
|
|
||||||
table.timestamp('executedAt').nullable();
|
|
||||||
table.text('error').nullable();
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
table.index(['tenantId']);
|
|
||||||
table.index(['status']);
|
|
||||||
table.index(['created_at']);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = function (knex) {
|
|
||||||
return knex.schema.dropTableIfExists('custom_migrations');
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
};
|
|
||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Creates the saved_list_views table.
|
|
||||||
* Each row stores a named, reusable search/filter configuration for a specific
|
|
||||||
* CRM object type. Views can be private to the owning user or shared with the
|
|
||||||
* whole tenant.
|
|
||||||
*
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.up = function (knex) {
|
|
||||||
return knex.schema.createTable('saved_list_views', (table) => {
|
|
||||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
|
||||||
|
|
||||||
// Human-readable name given by the user (or AI-suggested)
|
|
||||||
table.string('name').notNullable();
|
|
||||||
|
|
||||||
// The object this view belongs to (e.g. "Dog", "Contact")
|
|
||||||
table.string('object_api_name').notNullable();
|
|
||||||
|
|
||||||
// The user who created/owns this view
|
|
||||||
table.uuid('user_id').notNullable();
|
|
||||||
|
|
||||||
// When true the view is visible to all users in the tenant
|
|
||||||
table.boolean('is_shared').notNullable().defaultTo(false);
|
|
||||||
|
|
||||||
// Strategy is always "query" for saved views (keyword views are not saved)
|
|
||||||
table.string('strategy').notNullable().defaultTo('query');
|
|
||||||
|
|
||||||
// Resolved filters as JSON array of AiSearchFilter objects
|
|
||||||
table.json('filters').notNullable();
|
|
||||||
|
|
||||||
// Optional sort: { field: string, direction: "asc" | "desc" }
|
|
||||||
table.json('sort').nullable();
|
|
||||||
|
|
||||||
// AI-generated plain-language explanation of what this view shows
|
|
||||||
table.text('description').nullable();
|
|
||||||
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
// Foreign key to users
|
|
||||||
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE');
|
|
||||||
|
|
||||||
// Primary lookup: all views for an object visible to a user
|
|
||||||
table.index(['object_api_name', 'user_id']);
|
|
||||||
table.index(['object_api_name', 'is_shared']);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.down = function (knex) {
|
|
||||||
return knex.schema.dropTableIfExists('saved_list_views');
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inserts a system object_definition row for SavedListView.
|
|
||||||
* This allows saved_list_views records to be shared via record_shares
|
|
||||||
* (which requires a valid objectDefinitionId FK).
|
|
||||||
*
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.up = async function (knex) {
|
|
||||||
// Only insert if it doesn't already exist (idempotent)
|
|
||||||
const existing = await knex('object_definitions')
|
|
||||||
.where({ apiName: 'SavedListView' })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await knex('object_definitions').insert({
|
|
||||||
apiName: 'SavedListView',
|
|
||||||
label: 'Saved List View',
|
|
||||||
pluralLabel: 'Saved List Views',
|
|
||||||
description: 'System object for sharing saved list views via record_shares',
|
|
||||||
isSystem: true,
|
|
||||||
isCustom: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.down = async function (knex) {
|
|
||||||
await knex('object_definitions')
|
|
||||||
.where({ apiName: 'SavedListView' })
|
|
||||||
.delete();
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Add 'alias' and virtual 'name' column to users table.
|
|
||||||
*
|
|
||||||
* - alias: a user-editable display name / nickname
|
|
||||||
* - name: a generated column that returns COALESCE(alias, CONCAT(firstName, ' ', lastName), email)
|
|
||||||
* so that lookup fields referencing User.name always resolve.
|
|
||||||
*/
|
|
||||||
exports.up = function (knex) {
|
|
||||||
return knex.schema.alterTable('users', (table) => {
|
|
||||||
table.string('alias', 255).nullable().after('lastName');
|
|
||||||
table.string('name', 512).nullable().after('alias');
|
|
||||||
}).then(() => {
|
|
||||||
// Backfill existing rows: name = alias, or firstName + lastName, or email
|
|
||||||
return knex.raw(`
|
|
||||||
UPDATE users
|
|
||||||
SET name = COALESCE(
|
|
||||||
NULLIF(alias, ''),
|
|
||||||
NULLIF(TRIM(CONCAT(COALESCE(firstName, ''), ' ', COALESCE(lastName, ''))), ''),
|
|
||||||
email
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = function (knex) {
|
|
||||||
return knex.schema.alterTable('users', (table) => {
|
|
||||||
table.dropColumn('name');
|
|
||||||
table.dropColumn('alias');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.up = async function (knex) {
|
|
||||||
await knex.schema.createTable('comments', (table) => {
|
|
||||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
|
||||||
table.string('parent_object_api_name').notNullable();
|
|
||||||
table.uuid('parent_record_id').notNullable();
|
|
||||||
table.uuid('author_user_id').notNullable();
|
|
||||||
table.text('content').notNullable();
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
table.foreign('author_user_id').references('id').inTable('users').onDelete('CASCADE');
|
|
||||||
table.index(['parent_object_api_name', 'parent_record_id'], 'comments_parent_idx');
|
|
||||||
table.index(['author_user_id'], 'comments_author_idx');
|
|
||||||
});
|
|
||||||
|
|
||||||
await knex.schema.createTable('semantic_documents', (table) => {
|
|
||||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
|
||||||
table.string('entity_type').notNullable();
|
|
||||||
table.uuid('entity_id').notNullable();
|
|
||||||
table.string('title').nullable();
|
|
||||||
table.text('narrative').nullable();
|
|
||||||
table.json('metadata').nullable();
|
|
||||||
table.json('source_summary').nullable();
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
table.unique(['entity_type', 'entity_id'], {
|
|
||||||
indexName: 'semantic_documents_entity_unique',
|
|
||||||
});
|
|
||||||
table.index(['entity_type'], 'semantic_documents_type_idx');
|
|
||||||
});
|
|
||||||
|
|
||||||
await knex.schema.createTable('semantic_chunks', (table) => {
|
|
||||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
|
||||||
table.uuid('semantic_document_id').notNullable();
|
|
||||||
table.integer('chunk_index').notNullable();
|
|
||||||
table.string('source_kind').notNullable().defaultTo('base_record');
|
|
||||||
table.uuid('source_ref_id').nullable();
|
|
||||||
table.text('text').notNullable();
|
|
||||||
table.json('metadata').nullable();
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
table.foreign('semantic_document_id').references('id').inTable('semantic_documents').onDelete('CASCADE');
|
|
||||||
table.unique(['semantic_document_id', 'chunk_index'], {
|
|
||||||
indexName: 'semantic_chunks_doc_index_unique',
|
|
||||||
});
|
|
||||||
table.index(['semantic_document_id'], 'semantic_chunks_document_idx');
|
|
||||||
table.index(['source_kind'], 'semantic_chunks_source_kind_idx');
|
|
||||||
});
|
|
||||||
|
|
||||||
await knex.schema.createTable('semantic_links', (table) => {
|
|
||||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
|
||||||
table.string('source_entity_type', 100).notNullable();
|
|
||||||
table.uuid('source_entity_id').notNullable();
|
|
||||||
table.string('target_entity_type', 100).notNullable();
|
|
||||||
table.uuid('target_entity_id').notNullable();
|
|
||||||
table.string('link_type', 100).notNullable().defaultTo('related_to');
|
|
||||||
table.string('status').notNullable().defaultTo('suggested');
|
|
||||||
table.string('origin').notNullable().defaultTo('semantic');
|
|
||||||
table.decimal('confidence', 5, 4).notNullable().defaultTo(0);
|
|
||||||
table.text('reason').nullable();
|
|
||||||
table.json('evidence').nullable();
|
|
||||||
table.uuid('suggested_by_user_id').nullable();
|
|
||||||
table.uuid('reviewed_by_user_id').nullable();
|
|
||||||
table.timestamp('reviewed_at').nullable();
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
table.foreign('suggested_by_user_id').references('id').inTable('users').onDelete('SET NULL');
|
|
||||||
table.foreign('reviewed_by_user_id').references('id').inTable('users').onDelete('SET NULL');
|
|
||||||
|
|
||||||
table.unique(
|
|
||||||
['source_entity_type', 'source_entity_id', 'target_entity_type', 'target_entity_id', 'link_type'],
|
|
||||||
{ indexName: 'semantic_links_unique_pair_type' },
|
|
||||||
);
|
|
||||||
|
|
||||||
table.index(['source_entity_type', 'source_entity_id'], 'semantic_links_source_idx');
|
|
||||||
table.index(['target_entity_type', 'target_entity_id'], 'semantic_links_target_idx');
|
|
||||||
table.index(['status'], 'semantic_links_status_idx');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.down = async function (knex) {
|
|
||||||
await knex.schema.dropTableIfExists('semantic_links');
|
|
||||||
await knex.schema.dropTableIfExists('semantic_chunks');
|
|
||||||
await knex.schema.dropTableIfExists('semantic_documents');
|
|
||||||
await knex.schema.dropTableIfExists('comments');
|
|
||||||
};
|
|
||||||
1290
backend/package-lock.json
generated
1290
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,11 +26,6 @@
|
|||||||
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
|
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/bullmq": "^10.1.0",
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
@@ -38,28 +33,19 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-fastify": "^10.3.0",
|
"@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",
|
"@prisma/client": "^5.8.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.1.0",
|
"bullmq": "^5.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"deepagents": "^1.5.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"langchain": "^1.2.10",
|
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"objection": "^3.1.5",
|
"objection": "^3.1.5",
|
||||||
"openai": "^6.15.0",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"twilio": "^5.11.1",
|
|
||||||
"ws": "^8.18.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `tenants` ADD COLUMN `integrationsConfig` JSON NULL;
|
|
||||||
@@ -24,18 +24,17 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Tenant {
|
model Tenant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique // Used for identification
|
slug String @unique // Used for identification
|
||||||
dbHost String // Database host
|
dbHost String // Database host
|
||||||
dbPort Int @default(3306)
|
dbPort Int @default(3306)
|
||||||
dbName String // Database name
|
dbName String // Database name
|
||||||
dbUsername String // Database username
|
dbUsername String // Database username
|
||||||
dbPassword String // Encrypted database password
|
dbPassword String // Encrypted database password
|
||||||
integrationsConfig Json? // Encrypted JSON config for external services (Twilio, OpenAI, etc.)
|
status String @default("active") // active, suspended, deleted
|
||||||
status String @default("active") // active, suspended, deleted
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
domains Domain[]
|
domains Domain[]
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ model User {
|
|||||||
password String
|
password String
|
||||||
firstName String?
|
firstName String?
|
||||||
lastName String?
|
lastName String?
|
||||||
alias String?
|
|
||||||
name String?
|
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -127,7 +125,6 @@ model FieldDefinition {
|
|||||||
isSystem Boolean @default(false)
|
isSystem Boolean @default(false)
|
||||||
isCustom Boolean @default(true)
|
isCustom Boolean @default(true)
|
||||||
displayOrder Int @default(0)
|
displayOrder Int @default(0)
|
||||||
uiMetadata Json? @map("ui_metadata")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -147,42 +144,12 @@ model Account {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
contacts Contact[]
|
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("accounts")
|
@@map("accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Contact {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
firstName String
|
|
||||||
lastName String
|
|
||||||
accountId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([accountId])
|
|
||||||
@@map("contacts")
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactDetail {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
relatedObjectType String
|
|
||||||
relatedObjectId String
|
|
||||||
detailType String
|
|
||||||
label String?
|
|
||||||
value String
|
|
||||||
isPrimary Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([relatedObjectType, relatedObjectId])
|
|
||||||
@@map("contact_details")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Application Builder
|
// Application Builder
|
||||||
model App {
|
model App {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ function decryptPassword(encryptedPassword: string): string {
|
|||||||
function createTenantKnexConnection(tenant: any): Knex {
|
function createTenantKnexConnection(tenant: any): Knex {
|
||||||
const decryptedPassword = decryptPassword(tenant.dbPassword);
|
const decryptedPassword = decryptPassword(tenant.dbPassword);
|
||||||
|
|
||||||
// Use Docker hostname 'db' when running inside container
|
// Replace 'db' hostname with 'localhost' when running outside Docker
|
||||||
// The dbHost will be 'db' for Docker connections or 'localhost' for local development
|
const dbHost = tenant.dbHost === 'db' ? 'localhost' : tenant.dbHost;
|
||||||
const dbHost = tenant.dbHost;
|
|
||||||
|
|
||||||
return knex({
|
return knex({
|
||||||
client: 'mysql2',
|
client: 'mysql2',
|
||||||
@@ -83,7 +82,7 @@ async function migrateTenant(tenant: any): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ ${tenant.name}: Migration failed:`, error);
|
console.error(`❌ ${tenant.name}: Migration failed:`, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await tenantKnex.destroy();
|
await tenantKnex.destroy();
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1,49 +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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('suggest-view-name')
|
|
||||||
async suggestViewName(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Body() payload: { objectLabel: string; filters: any[]; explanation?: string },
|
|
||||||
) {
|
|
||||||
return this.aiAssistantService.suggestViewName(tenantId, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class AiChatMessageDto {
|
|
||||||
@IsIn(['user', 'assistant'])
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,14 @@ export class AppBuilderService {
|
|||||||
|
|
||||||
// Runtime endpoints
|
// Runtime endpoints
|
||||||
async getApps(tenantId: string, userId: string) {
|
async getApps(tenantId: string, userId: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
// For now, return all apps
|
// For now, return all apps
|
||||||
// In production, you'd filter by user permissions
|
// In production, you'd filter by user permissions
|
||||||
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
|
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getApp(tenantId: string, slug: string, userId: string) {
|
async getApp(tenantId: string, slug: string, userId: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await App.query(knex)
|
const app = await App.query(knex)
|
||||||
.findOne({ slug })
|
.findOne({ slug })
|
||||||
.withGraphFetched('pages');
|
.withGraphFetched('pages');
|
||||||
@@ -35,7 +35,7 @@ export class AppBuilderService {
|
|||||||
pageSlug: string,
|
pageSlug: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
) {
|
) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getApp(tenantId, appSlug, userId);
|
const app = await this.getApp(tenantId, appSlug, userId);
|
||||||
|
|
||||||
const page = await AppPage.query(knex).findOne({
|
const page = await AppPage.query(knex).findOne({
|
||||||
@@ -52,12 +52,12 @@ export class AppBuilderService {
|
|||||||
|
|
||||||
// Setup endpoints
|
// Setup endpoints
|
||||||
async getAllApps(tenantId: string) {
|
async getAllApps(tenantId: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
|
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAppForSetup(tenantId: string, slug: string) {
|
async getAppForSetup(tenantId: string, slug: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await App.query(knex)
|
const app = await App.query(knex)
|
||||||
.findOne({ slug })
|
.findOne({ slug })
|
||||||
.withGraphFetched('pages');
|
.withGraphFetched('pages');
|
||||||
@@ -77,7 +77,7 @@ export class AppBuilderService {
|
|||||||
description?: string;
|
description?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
return App.query(knex).insert({
|
return App.query(knex).insert({
|
||||||
...data,
|
...data,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
@@ -92,7 +92,7 @@ export class AppBuilderService {
|
|||||||
description?: string;
|
description?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getAppForSetup(tenantId, slug);
|
const app = await this.getAppForSetup(tenantId, slug);
|
||||||
|
|
||||||
return App.query(knex).patchAndFetchById(app.id, data);
|
return App.query(knex).patchAndFetchById(app.id, data);
|
||||||
@@ -109,7 +109,7 @@ export class AppBuilderService {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||||
|
|
||||||
return AppPage.query(knex).insert({
|
return AppPage.query(knex).insert({
|
||||||
@@ -133,7 +133,7 @@ export class AppBuilderService {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||||
|
|
||||||
const page = await AppPage.query(knex).findOne({
|
const page = await AppPage.query(knex).findOne({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
@@ -8,22 +7,12 @@ import { RbacModule } from './rbac/rbac.module';
|
|||||||
import { ObjectModule } from './object/object.module';
|
import { ObjectModule } from './object/object.module';
|
||||||
import { AppBuilderModule } from './app-builder/app-builder.module';
|
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||||
import { PageLayoutModule } from './page-layout/page-layout.module';
|
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||||
import { VoiceModule } from './voice/voice.module';
|
|
||||||
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
|
||||||
import { SavedListViewModule } from './saved-list-view/saved-list-view.module';
|
|
||||||
import { KnowledgeModule } from './knowledge/knowledge.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
BullModule.forRoot({
|
|
||||||
connection: {
|
|
||||||
host: process.env.REDIS_HOST || 'platform-redis',
|
|
||||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -31,10 +20,6 @@ import { KnowledgeModule } from './knowledge/knowledge.module';
|
|||||||
ObjectModule,
|
ObjectModule,
|
||||||
AppBuilderModule,
|
AppBuilderModule,
|
||||||
PageLayoutModule,
|
PageLayoutModule,
|
||||||
VoiceModule,
|
|
||||||
AiAssistantModule,
|
|
||||||
SavedListViewModule,
|
|
||||||
KnowledgeModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
Get,
|
|
||||||
Body,
|
Body,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
|
||||||
import { CurrentUser } from './current-user.decorator';
|
|
||||||
|
|
||||||
class LoginDto {
|
class LoginDto {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@@ -115,15 +111,4 @@ export class AuthController {
|
|||||||
// This endpoint exists for consistency and potential future enhancements
|
// This endpoint exists for consistency and potential future enhancements
|
||||||
return { message: 'Logged out successfully' };
|
return { message: 'Logged out successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('me')
|
|
||||||
async me(@CurrentUser() user: any, @TenantId() tenantId: string) {
|
|
||||||
// Return the current authenticated user info
|
|
||||||
return {
|
|
||||||
id: user.userId,
|
|
||||||
email: user.email,
|
|
||||||
tenantId: tenantId || user.tenantId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, validate as tenant user
|
// Otherwise, validate as tenant user
|
||||||
const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId);
|
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const user = await tenantDb('users')
|
const user = await tenantDb('users')
|
||||||
.where({ email })
|
.where({ email })
|
||||||
.first();
|
.first();
|
||||||
@@ -113,7 +113,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, register as tenant user
|
// Otherwise, register as tenant user
|
||||||
const tenantDb = await this.tenantDbService.getTenantKnexById(tenantId);
|
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
export type SemanticProjectionInput = {
|
|
||||||
objectApiName: string;
|
|
||||||
record: Record<string, any>;
|
|
||||||
objectDefinition?: any;
|
|
||||||
comments: Array<{ id: string; content: string; author_user_id: string; created_at?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SemanticProjection = {
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
title: string;
|
|
||||||
narrative: string;
|
|
||||||
/** Plain text used for embedding — no 'key: value' labels, no comments (chunker handles those separately). */
|
|
||||||
embeddingNarrative: string;
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
sourceSummary: {
|
|
||||||
includedFieldCount: number;
|
|
||||||
includedCommentCount: number;
|
|
||||||
includesComments: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SemanticProjectionAdapter {
|
|
||||||
supports(objectApiName: string): boolean;
|
|
||||||
buildProjection(input: SemanticProjectionInput): SemanticProjection;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXCLUDED_FIELDS = new Set([
|
|
||||||
'id',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
'ownerId',
|
|
||||||
'owner_id',
|
|
||||||
'tenantId',
|
|
||||||
'tenant_id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export class DefaultSemanticProjectionAdapter implements SemanticProjectionAdapter {
|
|
||||||
supports(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildProjection(input: SemanticProjectionInput): SemanticProjection {
|
|
||||||
const fieldEntries = Object.entries(input.record || {}).filter(([key, value]) => {
|
|
||||||
if (EXCLUDED_FIELDS.has(key)) return false;
|
|
||||||
if (value === null || value === undefined || value === '') return false;
|
|
||||||
return ['string', 'number', 'boolean'].includes(typeof value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const title =
|
|
||||||
input.record?.name ||
|
|
||||||
input.record?.title ||
|
|
||||||
input.record?.subject ||
|
|
||||||
`${input.objectApiName} ${input.record?.id || ''}`.trim();
|
|
||||||
|
|
||||||
|
|
||||||
const fieldNarrative = fieldEntries
|
|
||||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const commentNarrative = (input.comments || [])
|
|
||||||
.map((comment, index) => `Comment ${index + 1}: ${comment.content}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const narrative = [fieldNarrative, commentNarrative].filter(Boolean).join('\n\n');
|
|
||||||
|
|
||||||
// Plain values only — no 'key:' prefixes. Comments are handled separately by the chunker.
|
|
||||||
const embeddingNarrative = fieldEntries
|
|
||||||
.map(([, value]) => String(value))
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return {
|
|
||||||
entityType: input.objectApiName,
|
|
||||||
entityId: input.record.id,
|
|
||||||
title,
|
|
||||||
narrative,
|
|
||||||
embeddingNarrative,
|
|
||||||
metadata: {
|
|
||||||
objectApiName: input.objectApiName,
|
|
||||||
hasComments: (input.comments || []).length > 0,
|
|
||||||
},
|
|
||||||
sourceSummary: {
|
|
||||||
includedFieldCount: fieldEntries.length,
|
|
||||||
includedCommentCount: (input.comments || []).length,
|
|
||||||
includesComments: (input.comments || []).length > 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateCommentDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
parentObjectApiName: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
parentRecordId: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(10000)
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateCommentDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(10000)
|
|
||||||
content?: string;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { IsIn, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export const SEMANTIC_LINK_STATUSES = ['suggested', 'approved', 'rejected', 'dismissed'] as const;
|
|
||||||
export const SEMANTIC_LINK_ORIGINS = ['manual', 'semantic', 'llm', 'hybrid', 'rule_based'] as const;
|
|
||||||
|
|
||||||
export class ReviewSemanticLinkDto {
|
|
||||||
@IsString()
|
|
||||||
@IsIn(SEMANTIC_LINK_STATUSES)
|
|
||||||
status: (typeof SEMANTIC_LINK_STATUSES)[number];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpsertSemanticLinkDto {
|
|
||||||
@IsString()
|
|
||||||
sourceEntityType: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
sourceEntityId: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
targetEntityType: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
targetEntityId: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
linkType?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(SEMANTIC_LINK_STATUSES)
|
|
||||||
status?: (typeof SEMANTIC_LINK_STATUSES)[number];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(SEMANTIC_LINK_ORIGINS)
|
|
||||||
origin?: (typeof SEMANTIC_LINK_ORIGINS)[number];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
@Max(1)
|
|
||||||
confidence?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reason?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
evidence?: Record<string, any>;
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
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 { CreateCommentDto, UpdateCommentDto } from './dto/comment.dto';
|
|
||||||
import { ReviewSemanticLinkDto } from './dto/semantic-link.dto';
|
|
||||||
import { CommentService } from './services/comment.service';
|
|
||||||
import { SemanticOrchestratorService } from './services/semantic-orchestrator.service';
|
|
||||||
import { SemanticLinkService } from './services/semantic-link.service';
|
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|
||||||
|
|
||||||
@Controller('knowledge')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class KnowledgeController {
|
|
||||||
constructor(
|
|
||||||
private readonly commentService: CommentService,
|
|
||||||
private readonly semanticOrchestratorService: SemanticOrchestratorService,
|
|
||||||
private readonly semanticLinkService: SemanticLinkService,
|
|
||||||
private readonly tenantDbService: TenantDatabaseService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('comments/:objectApiName/:recordId')
|
|
||||||
async getComments(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('objectApiName') objectApiName: string,
|
|
||||||
@Param('recordId') recordId: string,
|
|
||||||
) {
|
|
||||||
return this.commentService.listComments(tenantId, objectApiName, recordId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('comments')
|
|
||||||
async createComment(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Body() dto: CreateCommentDto,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
) {
|
|
||||||
return this.commentService.createComment(tenantId, dto, user.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('comments/:id')
|
|
||||||
async updateComment(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: UpdateCommentDto,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
) {
|
|
||||||
return this.commentService.updateComment(tenantId, id, dto, user.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('comments/:id')
|
|
||||||
async deleteComment(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
) {
|
|
||||||
return this.commentService.deleteComment(tenantId, id, user.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('semantic/refresh/:objectApiName/:recordId')
|
|
||||||
async refreshSemantic(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('objectApiName') objectApiName: string,
|
|
||||||
@Param('recordId') recordId: string,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
) {
|
|
||||||
return this.semanticOrchestratorService.refreshRecord(
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
recordId,
|
|
||||||
user.userId,
|
|
||||||
'manual_refresh',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('semantic/reindex/:objectApiName')
|
|
||||||
async reindexObject(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('objectApiName') objectApiName: string,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
@Query('limit') limit?: string,
|
|
||||||
) {
|
|
||||||
const parsedLimit = Number.isFinite(Number(limit)) ? Number(limit) : 250;
|
|
||||||
return this.semanticOrchestratorService.reindexObject(
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
user.userId,
|
|
||||||
parsedLimit,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('semantic/links/:objectApiName/:recordId')
|
|
||||||
async listLinks(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('objectApiName') objectApiName: string,
|
|
||||||
@Param('recordId') recordId: string,
|
|
||||||
@Query('status') status?: string,
|
|
||||||
) {
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
return this.semanticLinkService.listForRecord(knex, objectApiName, recordId, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('semantic/links/:id/review')
|
|
||||||
async reviewLink(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: ReviewSemanticLinkDto,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
) {
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
return this.semanticLinkService.reviewLink(knex, id, dto.status, user.userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
import { KnowledgeController } from './knowledge.controller';
|
|
||||||
import { CommentService } from './services/comment.service';
|
|
||||||
import { SemanticOrchestratorService } from './services/semantic-orchestrator.service';
|
|
||||||
import { SemanticChunkerService } from './services/semantic-chunker.service';
|
|
||||||
import { SemanticLinkService } from './services/semantic-link.service';
|
|
||||||
import { SemanticRefreshQueueService } from './services/semantic-refresh-queue.service';
|
|
||||||
import { SemanticRefreshProcessor } from './semantic-refresh.processor';
|
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
|
||||||
import { MeilisearchModule } from '../search/meilisearch.module';
|
|
||||||
import { SEMANTIC_REFRESH_QUEUE } from './semantic-refresh.constants';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TenantModule,
|
|
||||||
MeilisearchModule,
|
|
||||||
BullModule.registerQueue({ name: SEMANTIC_REFRESH_QUEUE }),
|
|
||||||
],
|
|
||||||
controllers: [KnowledgeController],
|
|
||||||
providers: [
|
|
||||||
CommentService,
|
|
||||||
SemanticOrchestratorService,
|
|
||||||
SemanticChunkerService,
|
|
||||||
SemanticLinkService,
|
|
||||||
SemanticRefreshQueueService,
|
|
||||||
SemanticRefreshProcessor,
|
|
||||||
],
|
|
||||||
exports: [SemanticOrchestratorService, SemanticRefreshQueueService],
|
|
||||||
})
|
|
||||||
export class KnowledgeModule {}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const SEMANTIC_REFRESH_QUEUE = 'semantic-refresh';
|
|
||||||
|
|
||||||
export const SEMANTIC_REFRESH_JOB = 'refresh-record';
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { SemanticOrchestratorService } from './services/semantic-orchestrator.service';
|
|
||||||
import { SEMANTIC_REFRESH_QUEUE } from './semantic-refresh.constants';
|
|
||||||
|
|
||||||
export type SemanticRefreshJobData = {
|
|
||||||
tenantId: string;
|
|
||||||
objectApiName: string;
|
|
||||||
recordId: string;
|
|
||||||
userId?: string;
|
|
||||||
trigger: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Processor(SEMANTIC_REFRESH_QUEUE)
|
|
||||||
export class SemanticRefreshProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(SemanticRefreshProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly semanticOrchestratorService: SemanticOrchestratorService,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<SemanticRefreshJobData>): Promise<void> {
|
|
||||||
const { tenantId, objectApiName, recordId, userId, trigger } = job.data;
|
|
||||||
this.logger.log(
|
|
||||||
`Processing semantic refresh: ${objectApiName}:${recordId} trigger=${trigger}`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await this.semanticOrchestratorService.refreshRecord(
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
recordId,
|
|
||||||
userId,
|
|
||||||
trigger,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Semantic refresh failed: ${objectApiName}:${recordId} trigger=${trigger} error=${error.message}`,
|
|
||||||
);
|
|
||||||
throw error; // Let BullMQ handle retries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
|
|
||||||
import { CreateCommentDto, UpdateCommentDto } from '../dto/comment.dto';
|
|
||||||
import { SemanticRefreshQueueService } from './semantic-refresh-queue.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CommentService {
|
|
||||||
constructor(
|
|
||||||
private readonly tenantDbService: TenantDatabaseService,
|
|
||||||
private readonly semanticRefreshQueue: SemanticRefreshQueueService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listComments(tenantId: string, parentObjectApiName: string, parentRecordId: string) {
|
|
||||||
const knex = await this.getKnex(tenantId);
|
|
||||||
return knex('comments')
|
|
||||||
.where({
|
|
||||||
parent_object_api_name: parentObjectApiName,
|
|
||||||
parent_record_id: parentRecordId,
|
|
||||||
})
|
|
||||||
.orderBy('created_at', 'desc');
|
|
||||||
}
|
|
||||||
|
|
||||||
async createComment(tenantId: string, dto: CreateCommentDto, userId: string) {
|
|
||||||
const knex = await this.getKnex(tenantId);
|
|
||||||
const [created] = await knex('comments')
|
|
||||||
.insert({
|
|
||||||
parent_object_api_name: dto.parentObjectApiName,
|
|
||||||
parent_record_id: dto.parentRecordId,
|
|
||||||
author_user_id: userId,
|
|
||||||
content: dto.content,
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
})
|
|
||||||
.returning('*');
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Knowledge] Comment created: ${dto.parentObjectApiName}:${dto.parentRecordId} by ${userId}`,
|
|
||||||
);
|
|
||||||
await this.semanticRefreshQueue.enqueue(
|
|
||||||
tenantId,
|
|
||||||
dto.parentObjectApiName,
|
|
||||||
dto.parentRecordId,
|
|
||||||
userId,
|
|
||||||
'comment_created',
|
|
||||||
);
|
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateComment(tenantId: string, commentId: string, dto: UpdateCommentDto, userId: string) {
|
|
||||||
const knex = await this.getKnex(tenantId);
|
|
||||||
const existing = await knex('comments').where({ id: commentId }).first();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
throw new NotFoundException('Comment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.author_user_id !== userId) {
|
|
||||||
throw new ForbiddenException('Only the author can edit this comment');
|
|
||||||
}
|
|
||||||
|
|
||||||
await knex('comments')
|
|
||||||
.where({ id: commentId })
|
|
||||||
.update({
|
|
||||||
...(dto.content ? { content: dto.content } : {}),
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Knowledge] Comment updated: ${existing.parent_object_api_name}:${existing.parent_record_id} by ${userId}`,
|
|
||||||
);
|
|
||||||
await this.semanticRefreshQueue.enqueue(
|
|
||||||
tenantId,
|
|
||||||
existing.parent_object_api_name,
|
|
||||||
existing.parent_record_id,
|
|
||||||
userId,
|
|
||||||
'comment_updated',
|
|
||||||
);
|
|
||||||
|
|
||||||
return knex('comments').where({ id: commentId }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteComment(tenantId: string, commentId: string, userId: string) {
|
|
||||||
const knex = await this.getKnex(tenantId);
|
|
||||||
const existing = await knex('comments').where({ id: commentId }).first();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
throw new NotFoundException('Comment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.author_user_id !== userId) {
|
|
||||||
throw new ForbiddenException('Only the author can delete this comment');
|
|
||||||
}
|
|
||||||
|
|
||||||
await knex('comments').where({ id: commentId }).delete();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Knowledge] Comment deleted: ${existing.parent_object_api_name}:${existing.parent_record_id} by ${userId}`,
|
|
||||||
);
|
|
||||||
await this.semanticRefreshQueue.enqueue(
|
|
||||||
tenantId,
|
|
||||||
existing.parent_object_api_name,
|
|
||||||
existing.parent_record_id,
|
|
||||||
userId,
|
|
||||||
'comment_deleted',
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getKnex(tenantId: string) {
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
return this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { SemanticChunkerService } from './semantic-chunker.service';
|
|
||||||
|
|
||||||
describe('SemanticChunkerService', () => {
|
|
||||||
let service: SemanticChunkerService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new SemanticChunkerService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates chunks from base narrative and comments', () => {
|
|
||||||
const chunks = service.chunkText('Intro paragraph\n\nSecond paragraph', [
|
|
||||||
{ id: 'c-1', content: 'Comment body' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(chunks).toHaveLength(3);
|
|
||||||
expect(chunks[0].sourceKind).toBe('base_record');
|
|
||||||
expect(chunks[2].sourceKind).toBe('comment');
|
|
||||||
expect(chunks[2].sourceRefId).toBe('c-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
export type SemanticChunk = {
|
|
||||||
chunkIndex: number;
|
|
||||||
sourceKind: 'base_record' | 'comment' | 'mixed';
|
|
||||||
sourceRefId: string | null;
|
|
||||||
text: string;
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SemanticChunkerService {
|
|
||||||
chunkText(
|
|
||||||
baseNarrative: string,
|
|
||||||
comments: Array<{ id: string; content: string }>,
|
|
||||||
): SemanticChunk[] {
|
|
||||||
const chunks: SemanticChunk[] = [];
|
|
||||||
|
|
||||||
const baseParts = this.splitText(baseNarrative);
|
|
||||||
for (const [index, text] of baseParts.entries()) {
|
|
||||||
chunks.push({
|
|
||||||
chunkIndex: chunks.length,
|
|
||||||
sourceKind: 'base_record',
|
|
||||||
sourceRefId: null,
|
|
||||||
text,
|
|
||||||
metadata: { section: 'base', localIndex: index },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const comment of comments || []) {
|
|
||||||
const commentParts = this.splitText(comment.content);
|
|
||||||
for (const [index, text] of commentParts.entries()) {
|
|
||||||
chunks.push({
|
|
||||||
chunkIndex: chunks.length,
|
|
||||||
sourceKind: 'comment',
|
|
||||||
sourceRefId: comment.id,
|
|
||||||
text,
|
|
||||||
metadata: { section: 'comment', localIndex: index, commentId: comment.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
private splitText(text: string): string[] {
|
|
||||||
const normalized = (text || '').trim();
|
|
||||||
if (!normalized) return [];
|
|
||||||
|
|
||||||
const paragraphs = normalized
|
|
||||||
.split(/\n{2,}/)
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const chunks: string[] = [];
|
|
||||||
for (const paragraph of paragraphs) {
|
|
||||||
if (paragraph.length <= 500) {
|
|
||||||
chunks.push(paragraph);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
while (cursor < paragraph.length) {
|
|
||||||
chunks.push(paragraph.slice(cursor, cursor + 500).trim());
|
|
||||||
cursor += 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks.filter(Boolean);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { SemanticLinkService } from './semantic-link.service';
|
|
||||||
|
|
||||||
describe('SemanticLinkService', () => {
|
|
||||||
let service: SemanticLinkService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new SemanticLinkService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes undirected pairs in deterministic order', () => {
|
|
||||||
const normalized = service.normalizeUndirectedPair('Contact', 'b-id', 'Account', 'a-id');
|
|
||||||
|
|
||||||
expect(normalized).toEqual({
|
|
||||||
sourceEntityType: 'Account',
|
|
||||||
sourceEntityId: 'a-id',
|
|
||||||
targetEntityType: 'Contact',
|
|
||||||
targetEntityId: 'b-id',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
|
|
||||||
export type SemanticLinkUpsertInput = {
|
|
||||||
sourceEntityType: string;
|
|
||||||
sourceEntityId: string;
|
|
||||||
targetEntityType: string;
|
|
||||||
targetEntityId: string;
|
|
||||||
linkType?: string;
|
|
||||||
status?: string;
|
|
||||||
origin?: string;
|
|
||||||
confidence?: number;
|
|
||||||
reason?: string;
|
|
||||||
evidence?: Record<string, any>;
|
|
||||||
suggestedByUserId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SemanticLinkService {
|
|
||||||
normalizeUndirectedPair(
|
|
||||||
sourceEntityType: string,
|
|
||||||
sourceEntityId: string,
|
|
||||||
targetEntityType: string,
|
|
||||||
targetEntityId: string,
|
|
||||||
) {
|
|
||||||
const sourceKey = `${sourceEntityType}:${sourceEntityId}`;
|
|
||||||
const targetKey = `${targetEntityType}:${targetEntityId}`;
|
|
||||||
|
|
||||||
if (sourceKey <= targetKey) {
|
|
||||||
return {
|
|
||||||
sourceEntityType,
|
|
||||||
sourceEntityId,
|
|
||||||
targetEntityType,
|
|
||||||
targetEntityId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceEntityType: targetEntityType,
|
|
||||||
sourceEntityId: targetEntityId,
|
|
||||||
targetEntityType: sourceEntityType,
|
|
||||||
targetEntityId: sourceEntityId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertSuggestedLink(knex: any, input: SemanticLinkUpsertInput) {
|
|
||||||
const normalized = this.normalizeUndirectedPair(
|
|
||||||
input.sourceEntityType,
|
|
||||||
input.sourceEntityId,
|
|
||||||
input.targetEntityType,
|
|
||||||
input.targetEntityId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
source_entity_type: normalized.sourceEntityType,
|
|
||||||
source_entity_id: normalized.sourceEntityId,
|
|
||||||
target_entity_type: normalized.targetEntityType,
|
|
||||||
target_entity_id: normalized.targetEntityId,
|
|
||||||
link_type: input.linkType || 'related_to',
|
|
||||||
status: input.status || 'suggested',
|
|
||||||
origin: input.origin || 'semantic',
|
|
||||||
confidence: input.confidence ?? 0,
|
|
||||||
reason: input.reason || null,
|
|
||||||
evidence: input.evidence ? JSON.stringify(input.evidence) : null,
|
|
||||||
suggested_by_user_id: input.suggestedByUserId || null,
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await knex('semantic_links')
|
|
||||||
.insert(payload)
|
|
||||||
.onConflict([
|
|
||||||
'source_entity_type',
|
|
||||||
'source_entity_id',
|
|
||||||
'target_entity_type',
|
|
||||||
'target_entity_id',
|
|
||||||
'link_type',
|
|
||||||
])
|
|
||||||
.merge({
|
|
||||||
status: knex.raw("IF(status = 'approved', status, VALUES(status))"),
|
|
||||||
origin: payload.origin,
|
|
||||||
confidence: knex.raw('GREATEST(confidence, VALUES(confidence))'),
|
|
||||||
reason: payload.reason,
|
|
||||||
evidence: payload.evidence,
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async listForRecord(knex: any, entityType: string, entityId: string, status?: string) {
|
|
||||||
const query = knex('semantic_links')
|
|
||||||
.where((builder: any) => {
|
|
||||||
builder
|
|
||||||
.where({ source_entity_type: entityType, source_entity_id: entityId })
|
|
||||||
.orWhere({ target_entity_type: entityType, target_entity_id: entityId });
|
|
||||||
})
|
|
||||||
.orderBy('updated_at', 'desc');
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
query.andWhere({ status });
|
|
||||||
}
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
async reviewLink(knex: any, linkId: string, status: string, reviewerUserId: string) {
|
|
||||||
const updated = await knex('semantic_links')
|
|
||||||
.where({ id: linkId })
|
|
||||||
.update({
|
|
||||||
status,
|
|
||||||
reviewed_by_user_id: reviewerUserId,
|
|
||||||
reviewed_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new NotFoundException('Semantic link not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return knex('semantic_links').where({ id: linkId }).first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
|
|
||||||
import { MeilisearchService } from '../../search/meilisearch.service';
|
|
||||||
import { getCentralPrisma } from '../../prisma/central-prisma.service';
|
|
||||||
import { OpenAIConfig } from '../../voice/interfaces/integration-config.interface';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import {
|
|
||||||
DefaultSemanticProjectionAdapter,
|
|
||||||
SemanticProjectionAdapter,
|
|
||||||
} from '../adapters/semantic-projection.adapter';
|
|
||||||
import { SemanticChunkerService } from './semantic-chunker.service';
|
|
||||||
import { SemanticLinkService } from './semantic-link.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SemanticOrchestratorService {
|
|
||||||
private readonly logger = new Logger(SemanticOrchestratorService.name);
|
|
||||||
private readonly adapters: SemanticProjectionAdapter[] = [new DefaultSemanticProjectionAdapter()];
|
|
||||||
private readonly defaultEmbeddingModel =
|
|
||||||
process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small';
|
|
||||||
private readonly semanticEmbedderName = 'default';
|
|
||||||
private readonly MIN_CONFIDENCE_BASE = 0.7;
|
|
||||||
private readonly MIN_CONFIDENCE_COMMENT = 0.52;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly tenantDbService: TenantDatabaseService,
|
|
||||||
private readonly meilisearchService: MeilisearchService,
|
|
||||||
private readonly chunkerService: SemanticChunkerService,
|
|
||||||
private readonly semanticLinkService: SemanticLinkService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async refreshRecord(
|
|
||||||
tenantId: string,
|
|
||||||
objectApiName: string,
|
|
||||||
recordId: string,
|
|
||||||
userId?: string,
|
|
||||||
trigger: string = 'manual',
|
|
||||||
) {
|
|
||||||
this.logger.log(
|
|
||||||
`Semantic refresh start: ${objectApiName}:${recordId} (trigger=${trigger})`,
|
|
||||||
);
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
|
|
||||||
const objectDefinition = await knex('object_definitions').where({ apiName: objectApiName }).first();
|
|
||||||
if (!objectDefinition) {
|
|
||||||
this.logger.warn(`Object definition ${objectApiName} not found. Skipping semantic refresh.`);
|
|
||||||
return { skipped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableName = this.getTableName(objectDefinition);
|
|
||||||
const record = await knex(tableName).where({ id: recordId }).first();
|
|
||||||
if (!record) {
|
|
||||||
this.logger.warn(`Record not found for semantic refresh: ${objectApiName}:${recordId}`);
|
|
||||||
return { skipped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const comments = await knex('comments')
|
|
||||||
.where({
|
|
||||||
parent_object_api_name: objectApiName,
|
|
||||||
parent_record_id: recordId,
|
|
||||||
})
|
|
||||||
.orderBy('created_at', 'asc');
|
|
||||||
this.logger.log(
|
|
||||||
`Semantic refresh source: ${objectApiName}:${recordId} comments=${comments.length}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const adapter = this.adapters.find((candidate) => candidate.supports(objectApiName))!;
|
|
||||||
const projection = adapter.buildProjection({
|
|
||||||
objectApiName,
|
|
||||||
record,
|
|
||||||
objectDefinition,
|
|
||||||
comments,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentId = await this.upsertSemanticDocument(knex, projection);
|
|
||||||
const chunks = this.chunkerService.chunkText(projection.embeddingNarrative, comments);
|
|
||||||
this.logger.log(
|
|
||||||
`Semantic refresh chunking: ${objectApiName}:${recordId} chunks=${chunks.length}`,
|
|
||||||
);
|
|
||||||
await this.replaceChunks(knex, documentId, chunks);
|
|
||||||
|
|
||||||
const openAiConfig = await this.getOpenAiConfig(resolvedTenantId);
|
|
||||||
const embedderReady = await this.indexChunks(resolvedTenantId, projection, chunks, openAiConfig);
|
|
||||||
await this.generateSuggestions(
|
|
||||||
resolvedTenantId,
|
|
||||||
projection,
|
|
||||||
chunks,
|
|
||||||
openAiConfig,
|
|
||||||
embedderReady,
|
|
||||||
userId,
|
|
||||||
trigger,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Semantic refresh complete: ${objectApiName}:${recordId} document=${documentId}`,
|
|
||||||
);
|
|
||||||
return { documentId, chunkCount: chunks.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
async reindexObject(tenantId: string, objectApiName: string, userId?: string, limit = 250) {
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
const objectDefinition = await knex('object_definitions').where({ apiName: objectApiName }).first();
|
|
||||||
if (!objectDefinition) {
|
|
||||||
return { total: 0, processed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableName = this.getTableName(objectDefinition);
|
|
||||||
const records = await knex(tableName).select('id').limit(limit);
|
|
||||||
|
|
||||||
let processed = 0;
|
|
||||||
for (const record of records) {
|
|
||||||
await this.refreshRecord(resolvedTenantId, objectApiName, record.id, userId, 'batch_reindex');
|
|
||||||
processed += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { total: records.length, processed };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertSemanticDocument(knex: any, projection: any): Promise<string> {
|
|
||||||
const existing = await knex('semantic_documents')
|
|
||||||
.where({ entity_type: projection.entityType, entity_id: projection.entityId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await knex('semantic_documents')
|
|
||||||
.where({ id: existing.id })
|
|
||||||
.update({
|
|
||||||
title: projection.title,
|
|
||||||
narrative: projection.narrative,
|
|
||||||
metadata: JSON.stringify(projection.metadata || {}),
|
|
||||||
source_summary: JSON.stringify(projection.sourceSummary || {}),
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
});
|
|
||||||
return existing.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = randomUUID();
|
|
||||||
const [created] = await knex('semantic_documents')
|
|
||||||
.insert({
|
|
||||||
id: newId,
|
|
||||||
entity_type: projection.entityType,
|
|
||||||
entity_id: projection.entityId,
|
|
||||||
title: projection.title,
|
|
||||||
narrative: projection.narrative,
|
|
||||||
metadata: JSON.stringify(projection.metadata || {}),
|
|
||||||
source_summary: JSON.stringify(projection.sourceSummary || {}),
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
})
|
|
||||||
.returning('id');
|
|
||||||
|
|
||||||
if (created && typeof created === 'object' && created.id) {
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
// MySQL may return a numeric insert id (often 0 for UUID PKs). Always trust the generated UUID.
|
|
||||||
return newId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async replaceChunks(knex: any, documentId: string, chunks: any[]) {
|
|
||||||
if (!documentId) {
|
|
||||||
this.logger.warn('Skipping chunk replace: missing semantic document id.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await knex('semantic_chunks').where({ semantic_document_id: documentId }).delete();
|
|
||||||
if (!chunks.length) return;
|
|
||||||
|
|
||||||
await knex('semantic_chunks').insert(
|
|
||||||
chunks.map((chunk) => ({
|
|
||||||
semantic_document_id: documentId,
|
|
||||||
chunk_index: chunk.chunkIndex,
|
|
||||||
source_kind: chunk.sourceKind,
|
|
||||||
source_ref_id: chunk.sourceRefId,
|
|
||||||
text: chunk.text,
|
|
||||||
metadata: JSON.stringify(chunk.metadata || {}),
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async indexChunks(
|
|
||||||
tenantId: string,
|
|
||||||
projection: any,
|
|
||||||
chunks: any[],
|
|
||||||
openAiConfig: OpenAIConfig | null,
|
|
||||||
) {
|
|
||||||
if (!this.meilisearchService.isEnabled()) {
|
|
||||||
this.logger.warn('Meilisearch disabled; skipping semantic chunk indexing.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexName = this.meilisearchService.buildSemanticChunkIndexName(tenantId);
|
|
||||||
let embedderReady = false;
|
|
||||||
if (openAiConfig?.apiKey) {
|
|
||||||
embedderReady = await this.meilisearchService.ensureOpenAiEmbedder(indexName, {
|
|
||||||
embedderName: this.semanticEmbedderName,
|
|
||||||
apiKey: openAiConfig.apiKey,
|
|
||||||
model: openAiConfig.embeddingModel || this.defaultEmbeddingModel,
|
|
||||||
documentTemplate: '{{doc.title}}\n{{doc.text}}',
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`Meilisearch embedder ensured: index=${indexName} model=${openAiConfig.embeddingModel || this.defaultEmbeddingModel}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.logger.warn('OpenAI embedder not configured; semantic search will be lexical only.');
|
|
||||||
}
|
|
||||||
this.logger.log(`Indexing semantic chunks: index=${indexName} count=${chunks.length}`);
|
|
||||||
await this.meilisearchService.upsertDocuments(indexName, chunks.map((chunk) => ({
|
|
||||||
id: `${projection.entityType}_${projection.entityId}_${chunk.chunkIndex}`,
|
|
||||||
entityType: projection.entityType,
|
|
||||||
entityId: projection.entityId,
|
|
||||||
title: projection.title,
|
|
||||||
sourceKind: chunk.sourceKind,
|
|
||||||
sourceRefId: chunk.sourceRefId,
|
|
||||||
text: chunk.text,
|
|
||||||
})));
|
|
||||||
return embedderReady;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateSuggestions(
|
|
||||||
tenantId: string,
|
|
||||||
projection: any,
|
|
||||||
chunks: any[],
|
|
||||||
openAiConfig: OpenAIConfig | null,
|
|
||||||
embedderReady: boolean,
|
|
||||||
userId?: string,
|
|
||||||
trigger: string = 'semantic_refresh',
|
|
||||||
) {
|
|
||||||
if (!this.meilisearchService.isEnabled() || !chunks.length) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Skipping suggestion generation: meili=${this.meilisearchService.isEnabled()} chunks=${chunks.length}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexName = this.meilisearchService.buildSemanticChunkIndexName(tenantId);
|
|
||||||
// Build query from all chunks (base record + comments), prioritising comments
|
|
||||||
// since they carry the most distinctive semantic signal.
|
|
||||||
const commentChunks = chunks.filter((c) => c.sourceKind === 'comment');
|
|
||||||
const baseChunks = chunks.filter((c) => c.sourceKind !== 'comment');
|
|
||||||
const orderedChunks = [...commentChunks, ...baseChunks];
|
|
||||||
const queryText = orderedChunks.map((chunk) => chunk.text).join(' ').slice(0, 1200);
|
|
||||||
this.logger.log(
|
|
||||||
`Generating suggestions: index=${indexName} queryLen=${queryText.length} hybrid=${embedderReady}`,
|
|
||||||
);
|
|
||||||
const search = await this.meilisearchService.searchIndex(
|
|
||||||
indexName,
|
|
||||||
queryText,
|
|
||||||
20,
|
|
||||||
// semanticRatio:1.0 = pure vector search, no lexical component that would
|
|
||||||
// match on shared tokens like 'name:' or 'Comment 1:' across all records.
|
|
||||||
embedderReady ? { embedder: this.semanticEmbedderName, semanticRatio: 1.0 } : undefined,
|
|
||||||
);
|
|
||||||
this.logger.log(
|
|
||||||
`Meilisearch results: index=${indexName} hits=${search.hits?.length || 0} total=${search.total}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const candidates = new Map<string, { hit: any; confidence: number }>();
|
|
||||||
for (const hit of search.hits || []) {
|
|
||||||
// Skip self
|
|
||||||
if (hit.entityId === projection.entityId) continue;
|
|
||||||
|
|
||||||
const confidence = hit._semanticScore ?? hit._rankingScore ?? 0;
|
|
||||||
// Use a lower threshold for comment chunks (short, conversational text
|
|
||||||
// naturally produces lower cosine similarity than structured field values).
|
|
||||||
const isComment = hit.sourceKind === 'comment';
|
|
||||||
const threshold = isComment ? this.MIN_CONFIDENCE_COMMENT : this.MIN_CONFIDENCE_BASE;
|
|
||||||
this.logger.log(
|
|
||||||
`Suggestion candidate: ${hit.entityType}:${hit.entityId} confidence=${confidence.toFixed(4)} kind=${hit.sourceKind || 'base'} threshold=${threshold} text="${String(hit.text || '').substring(0, 60)}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confidence < threshold) {
|
|
||||||
this.logger.log(
|
|
||||||
`Skipping low-confidence match: ${hit.entityType}:${hit.entityId} confidence=${confidence.toFixed(4)} < ${threshold} (${isComment ? 'comment' : 'base'})`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${hit.entityType}:${hit.entityId}`;
|
|
||||||
const existing = candidates.get(key);
|
|
||||||
if (!existing || confidence > existing.confidence) {
|
|
||||||
candidates.set(key, { hit, confidence });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Filtered suggestions: ${candidates.size} passed thresholds (base=${this.MIN_CONFIDENCE_BASE}, comment=${this.MIN_CONFIDENCE_COMMENT})`);
|
|
||||||
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
||||||
|
|
||||||
for (const [key, { hit, confidence }] of candidates.entries()) {
|
|
||||||
const [targetType, targetId] = key.split(':');
|
|
||||||
await this.semanticLinkService.upsertSuggestedLink(knex, {
|
|
||||||
sourceEntityType: projection.entityType,
|
|
||||||
sourceEntityId: projection.entityId,
|
|
||||||
targetEntityType: targetType,
|
|
||||||
targetEntityId: targetId,
|
|
||||||
linkType: 'related_to',
|
|
||||||
status: 'suggested',
|
|
||||||
origin: 'semantic',
|
|
||||||
confidence,
|
|
||||||
reason: `Suggested from semantic similarity (${trigger})`,
|
|
||||||
evidence: {
|
|
||||||
trigger,
|
|
||||||
sourceSignals: chunks.slice(0, 2).map((chunk) => ({
|
|
||||||
sourceKind: chunk.sourceKind,
|
|
||||||
text: chunk.text.slice(0, 180),
|
|
||||||
})),
|
|
||||||
matchedChunks: [{
|
|
||||||
sourceKind: hit.sourceKind,
|
|
||||||
text: String(hit.text || '').slice(0, 180),
|
|
||||||
score: confidence,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
suggestedByUserId: userId || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTableName(objectDefinition: any): string {
|
|
||||||
if (objectDefinition.tableName) return objectDefinition.tableName;
|
|
||||||
|
|
||||||
if (objectDefinition.pluralLabel) {
|
|
||||||
return objectDefinition.pluralLabel.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${objectDefinition.apiName.toLowerCase()}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
|
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
||||||
const centralPrisma = getCentralPrisma();
|
|
||||||
const tenant = await centralPrisma.tenant.findUnique({
|
|
||||||
where: { id: resolvedTenantId },
|
|
||||||
select: { integrationsConfig: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
let config = tenant?.integrationsConfig
|
|
||||||
? typeof tenant.integrationsConfig === 'string'
|
|
||||||
? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig)
|
|
||||||
: tenant.integrationsConfig
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!config?.openai && process.env.OPENAI_API_KEY) {
|
|
||||||
config = {
|
|
||||||
...(config || {}),
|
|
||||||
openai: {
|
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
|
||||||
embeddingModel: this.defaultEmbeddingModel,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.openai?.apiKey) {
|
|
||||||
return {
|
|
||||||
apiKey: config.openai.apiKey,
|
|
||||||
embeddingModel: config.openai.embeddingModel || this.defaultEmbeddingModel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import {
|
|
||||||
SEMANTIC_REFRESH_QUEUE,
|
|
||||||
SEMANTIC_REFRESH_JOB,
|
|
||||||
} from '../semantic-refresh.constants';
|
|
||||||
import { SemanticRefreshJobData } from '../semantic-refresh.processor';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SemanticRefreshQueueService {
|
|
||||||
private readonly logger = new Logger(SemanticRefreshQueueService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectQueue(SEMANTIC_REFRESH_QUEUE) private readonly queue: Queue,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async enqueue(
|
|
||||||
tenantId: string,
|
|
||||||
objectApiName: string,
|
|
||||||
recordId: string,
|
|
||||||
userId?: string,
|
|
||||||
trigger: string = 'manual',
|
|
||||||
): Promise<void> {
|
|
||||||
const data: SemanticRefreshJobData = {
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
recordId,
|
|
||||||
userId,
|
|
||||||
trigger,
|
|
||||||
};
|
|
||||||
await this.queue.add(SEMANTIC_REFRESH_JOB, data, {
|
|
||||||
attempts: 3,
|
|
||||||
backoff: { type: 'exponential', delay: 2000 },
|
|
||||||
removeOnComplete: 100,
|
|
||||||
removeOnFail: 50,
|
|
||||||
});
|
|
||||||
this.logger.debug(
|
|
||||||
`Enqueued semantic refresh: ${objectApiName}:${recordId} trigger=${trigger}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,13 @@ import {
|
|||||||
FastifyAdapter,
|
FastifyAdapter,
|
||||||
NestFastifyApplication,
|
NestFastifyApplication,
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { VoiceService } from './voice/voice.service';
|
|
||||||
import { AudioConverterService } from './voice/audio-converter.service';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter({ logger: true }),
|
new FastifyAdapter(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global validation pipe
|
// Global validation pipe
|
||||||
@@ -35,145 +33,6 @@ async function bootstrap() {
|
|||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
// After app is listening, register WebSocket handler
|
|
||||||
const fastifyInstance = app.getHttpAdapter().getInstance();
|
|
||||||
const logger = new Logger('MediaStreamWS');
|
|
||||||
const voiceService = app.get(VoiceService);
|
|
||||||
const audioConverter = app.get(AudioConverterService);
|
|
||||||
|
|
||||||
const WebSocketServer = require('ws').Server;
|
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
|
||||||
|
|
||||||
// Handle WebSocket upgrades at the server level
|
|
||||||
const server = (fastifyInstance.server as any);
|
|
||||||
|
|
||||||
// Track active Media Streams connections: streamSid -> WebSocket
|
|
||||||
const mediaStreams: Map<string, any> = new Map();
|
|
||||||
|
|
||||||
server.on('upgrade', (request: any, socket: any, head: any) => {
|
|
||||||
if (request.url === '/api/voice/media-stream') {
|
|
||||||
logger.log('=== MEDIA STREAM WEBSOCKET UPGRADE REQUEST ===');
|
|
||||||
logger.log(`Path: ${request.url}`);
|
|
||||||
|
|
||||||
wss.handleUpgrade(request, socket, head, (ws: any) => {
|
|
||||||
logger.log('=== MEDIA STREAM WEBSOCKET UPGRADED SUCCESSFULLY ===');
|
|
||||||
handleMediaStreamSocket(ws);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleMediaStreamSocket(ws: any) {
|
|
||||||
let streamSid: string | null = null;
|
|
||||||
let callSid: string | null = null;
|
|
||||||
let tenantDomain: string | null = null;
|
|
||||||
let mediaPacketCount = 0;
|
|
||||||
|
|
||||||
ws.on('message', async (message: Buffer) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(message.toString());
|
|
||||||
|
|
||||||
switch (msg.event) {
|
|
||||||
case 'connected':
|
|
||||||
logger.log('=== MEDIA STREAM EVENT: CONNECTED ===');
|
|
||||||
logger.log(`Protocol: ${msg.protocol}`);
|
|
||||||
logger.log(`Version: ${msg.version}`);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'start':
|
|
||||||
streamSid = msg.streamSid;
|
|
||||||
callSid = msg.start.callSid;
|
|
||||||
tenantDomain = msg.start.customParameters?.tenantId || 'tenant1';
|
|
||||||
|
|
||||||
logger.log(`=== MEDIA STREAM EVENT: START ===`);
|
|
||||||
logger.log(`StreamSid: ${streamSid}`);
|
|
||||||
logger.log(`CallSid: ${callSid}`);
|
|
||||||
logger.log(`Tenant: ${tenantDomain}`);
|
|
||||||
logger.log(`MediaFormat: ${JSON.stringify(msg.start.mediaFormat)}`);
|
|
||||||
|
|
||||||
mediaStreams.set(streamSid, ws);
|
|
||||||
logger.log(`Stored WebSocket for streamSid: ${streamSid}. Total active streams: ${mediaStreams.size}`);
|
|
||||||
|
|
||||||
// Initialize OpenAI Realtime connection
|
|
||||||
logger.log(`Initializing OpenAI Realtime for call ${callSid}...`);
|
|
||||||
try {
|
|
||||||
await voiceService.initializeOpenAIRealtime({
|
|
||||||
callSid,
|
|
||||||
tenantId: tenantDomain,
|
|
||||||
userId: msg.start.customParameters?.userId || 'system',
|
|
||||||
});
|
|
||||||
logger.log(`✓ OpenAI Realtime initialized for call ${callSid}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to initialize OpenAI: ${error.message}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'media':
|
|
||||||
mediaPacketCount++;
|
|
||||||
// Only log every 500 packets to reduce noise
|
|
||||||
if (mediaPacketCount % 500 === 0) {
|
|
||||||
logger.log(`Received media packet #${mediaPacketCount} for StreamSid: ${streamSid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!callSid || !tenantDomain) {
|
|
||||||
logger.warn('Received media before start event');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert Twilio audio (μ-law 8kHz) to OpenAI format (PCM16 24kHz)
|
|
||||||
const twilioAudio = msg.media.payload;
|
|
||||||
const openaiAudio = audioConverter.twilioToOpenAI(twilioAudio);
|
|
||||||
|
|
||||||
// Send audio to OpenAI Realtime API
|
|
||||||
await voiceService.sendAudioToOpenAI(callSid, openaiAudio);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Error processing media: ${error.message}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'stop':
|
|
||||||
logger.log(`=== MEDIA STREAM EVENT: STOP ===`);
|
|
||||||
logger.log(`StreamSid: ${streamSid}`);
|
|
||||||
logger.log(`Total media packets received: ${mediaPacketCount}`);
|
|
||||||
|
|
||||||
if (streamSid) {
|
|
||||||
mediaStreams.delete(streamSid);
|
|
||||||
logger.log(`Removed WebSocket for streamSid: ${streamSid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up OpenAI connection
|
|
||||||
if (callSid) {
|
|
||||||
try {
|
|
||||||
logger.log(`Cleaning up OpenAI connection for call ${callSid}...`);
|
|
||||||
await voiceService.cleanupOpenAIConnection(callSid);
|
|
||||||
logger.log(`✓ OpenAI connection cleaned up`);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to cleanup OpenAI: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.debug(`Unknown media stream event: ${msg.event}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Error processing media stream message: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
logger.log(`=== MEDIA STREAM WEBSOCKET CLOSED ===`);
|
|
||||||
if (streamSid) {
|
|
||||||
mediaStreams.delete(streamSid);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (error: Error) => {
|
|
||||||
logger.error(`=== MEDIA STREAM WEBSOCKET ERROR ===`);
|
|
||||||
logger.error(`Error message: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
|
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Knex } from 'knex';
|
|
||||||
|
|
||||||
export interface CustomMigrationRecord {
|
|
||||||
id: string;
|
|
||||||
tenantId: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
|
||||||
sql: string;
|
|
||||||
status: 'pending' | 'executed' | 'failed';
|
|
||||||
executedAt?: Date;
|
|
||||||
error?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CustomMigrationService {
|
|
||||||
private readonly logger = new Logger(CustomMigrationService.name);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SQL to create a table with standard fields
|
|
||||||
*/
|
|
||||||
generateCreateTableSQL(
|
|
||||||
tableName: string,
|
|
||||||
fields: {
|
|
||||||
apiName: string;
|
|
||||||
type: string;
|
|
||||||
isRequired?: boolean;
|
|
||||||
isUnique?: boolean;
|
|
||||||
defaultValue?: string;
|
|
||||||
}[] = [],
|
|
||||||
): string {
|
|
||||||
// Start with standard fields
|
|
||||||
const columns: string[] = [
|
|
||||||
'`id` VARCHAR(36) PRIMARY KEY',
|
|
||||||
'`ownerId` VARCHAR(36)',
|
|
||||||
'`name` VARCHAR(255)',
|
|
||||||
'`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
|
|
||||||
'`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add custom fields
|
|
||||||
for (const field of fields) {
|
|
||||||
const column = this.fieldToColumn(field);
|
|
||||||
columns.push(column);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add foreign key and index for ownerId
|
|
||||||
columns.push('INDEX `idx_owner` (`ownerId`)');
|
|
||||||
|
|
||||||
return `CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
|
||||||
${columns.join(',\n ')}
|
|
||||||
)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert field definition to SQL column definition
|
|
||||||
*/
|
|
||||||
private fieldToColumn(field: {
|
|
||||||
apiName: string;
|
|
||||||
type: string;
|
|
||||||
isRequired?: boolean;
|
|
||||||
isUnique?: boolean;
|
|
||||||
defaultValue?: string;
|
|
||||||
}): string {
|
|
||||||
const columnName = field.apiName;
|
|
||||||
let columnDef = `\`${columnName}\``;
|
|
||||||
|
|
||||||
// Map field types to SQL types
|
|
||||||
switch (field.type.toUpperCase()) {
|
|
||||||
case 'TEXT':
|
|
||||||
case 'STRING':
|
|
||||||
columnDef += ' VARCHAR(255)';
|
|
||||||
break;
|
|
||||||
case 'LONG_TEXT':
|
|
||||||
columnDef += ' LONGTEXT';
|
|
||||||
break;
|
|
||||||
case 'NUMBER':
|
|
||||||
case 'DECIMAL':
|
|
||||||
columnDef += ' DECIMAL(18, 2)';
|
|
||||||
break;
|
|
||||||
case 'INTEGER':
|
|
||||||
columnDef += ' INT';
|
|
||||||
break;
|
|
||||||
case 'BOOLEAN':
|
|
||||||
columnDef += ' BOOLEAN DEFAULT FALSE';
|
|
||||||
break;
|
|
||||||
case 'DATE':
|
|
||||||
columnDef += ' DATE';
|
|
||||||
break;
|
|
||||||
case 'DATE_TIME':
|
|
||||||
columnDef += ' DATETIME';
|
|
||||||
break;
|
|
||||||
case 'EMAIL':
|
|
||||||
columnDef += ' VARCHAR(255)';
|
|
||||||
break;
|
|
||||||
case 'URL':
|
|
||||||
columnDef += ' VARCHAR(2048)';
|
|
||||||
break;
|
|
||||||
case 'PHONE':
|
|
||||||
columnDef += ' VARCHAR(20)';
|
|
||||||
break;
|
|
||||||
case 'CURRENCY':
|
|
||||||
columnDef += ' DECIMAL(18, 2)';
|
|
||||||
break;
|
|
||||||
case 'PERCENT':
|
|
||||||
columnDef += ' DECIMAL(5, 2)';
|
|
||||||
break;
|
|
||||||
case 'PICKLIST':
|
|
||||||
case 'MULTI_PICKLIST':
|
|
||||||
columnDef += ' VARCHAR(255)';
|
|
||||||
break;
|
|
||||||
case 'LOOKUP':
|
|
||||||
case 'BELONGS_TO':
|
|
||||||
columnDef += ' VARCHAR(36)';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
columnDef += ' VARCHAR(255)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add constraints
|
|
||||||
if (field.isRequired) {
|
|
||||||
columnDef += ' NOT NULL';
|
|
||||||
} else {
|
|
||||||
columnDef += ' NULL';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.isUnique) {
|
|
||||||
columnDef += ' UNIQUE';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
|
||||||
columnDef += ` DEFAULT '${field.defaultValue}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return columnDef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a custom migration record in the database
|
|
||||||
*/
|
|
||||||
async createMigrationRecord(
|
|
||||||
tenantKnex: Knex,
|
|
||||||
data: {
|
|
||||||
tenantId: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
|
||||||
sql: string;
|
|
||||||
},
|
|
||||||
): Promise<CustomMigrationRecord> {
|
|
||||||
// Ensure custom_migrations table exists
|
|
||||||
await this.ensureMigrationsTable(tenantKnex);
|
|
||||||
|
|
||||||
const id = require('crypto').randomUUID();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await tenantKnex('custom_migrations').insert({
|
|
||||||
id,
|
|
||||||
tenantId: data.tenantId,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
type: data.type,
|
|
||||||
sql: data.sql,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
return tenantKnex('custom_migrations').where({ id }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a pending migration and update its status
|
|
||||||
*/
|
|
||||||
async executeMigration(
|
|
||||||
tenantKnex: Knex,
|
|
||||||
migrationId: string,
|
|
||||||
): Promise<CustomMigrationRecord> {
|
|
||||||
try {
|
|
||||||
// Get the migration record
|
|
||||||
const migration = await tenantKnex('custom_migrations')
|
|
||||||
.where({ id: migrationId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!migration) {
|
|
||||||
throw new Error(`Migration ${migrationId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (migration.status === 'executed') {
|
|
||||||
this.logger.log(`Migration ${migrationId} already executed`);
|
|
||||||
return migration;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the SQL
|
|
||||||
this.logger.log(`Executing migration: ${migration.name}`);
|
|
||||||
await tenantKnex.raw(migration.sql);
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
const now = new Date();
|
|
||||||
await tenantKnex('custom_migrations')
|
|
||||||
.where({ id: migrationId })
|
|
||||||
.update({
|
|
||||||
status: 'executed',
|
|
||||||
executedAt: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Migration ${migration.name} executed successfully`);
|
|
||||||
return tenantKnex('custom_migrations').where({ id: migrationId }).first();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to execute migration ${migrationId}:`, error);
|
|
||||||
|
|
||||||
// Update status with error
|
|
||||||
const now = new Date();
|
|
||||||
await tenantKnex('custom_migrations')
|
|
||||||
.where({ id: migrationId })
|
|
||||||
.update({
|
|
||||||
status: 'failed',
|
|
||||||
error: error.message,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and execute a migration in one step
|
|
||||||
*/
|
|
||||||
async createAndExecuteMigration(
|
|
||||||
tenantKnex: Knex,
|
|
||||||
tenantId: string,
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
|
||||||
sql: string;
|
|
||||||
},
|
|
||||||
): Promise<CustomMigrationRecord> {
|
|
||||||
// Create the migration record
|
|
||||||
const migration = await this.createMigrationRecord(tenantKnex, {
|
|
||||||
tenantId,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute it immediately
|
|
||||||
return this.executeMigration(tenantKnex, migration.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the custom_migrations table exists in the tenant database
|
|
||||||
*/
|
|
||||||
private async ensureMigrationsTable(tenantKnex: Knex): Promise<void> {
|
|
||||||
const hasTable = await tenantKnex.schema.hasTable('custom_migrations');
|
|
||||||
|
|
||||||
if (!hasTable) {
|
|
||||||
await tenantKnex.schema.createTable('custom_migrations', (table) => {
|
|
||||||
table.uuid('id').primary();
|
|
||||||
table.uuid('tenantId').notNullable();
|
|
||||||
table.string('name', 255).notNullable();
|
|
||||||
table.text('description');
|
|
||||||
table.enum('type', ['create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom']).notNullable();
|
|
||||||
table.text('sql').notNullable();
|
|
||||||
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
|
|
||||||
table.timestamp('executedAt').nullable();
|
|
||||||
table.text('error').nullable();
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
table.index(['tenantId']);
|
|
||||||
table.index(['status']);
|
|
||||||
table.index(['created_at']);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log('Created custom_migrations table');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all migrations for a tenant
|
|
||||||
*/
|
|
||||||
async getMigrations(
|
|
||||||
tenantKnex: Knex,
|
|
||||||
tenantId: string,
|
|
||||||
filter?: {
|
|
||||||
status?: 'pending' | 'executed' | 'failed';
|
|
||||||
type?: string;
|
|
||||||
},
|
|
||||||
): Promise<CustomMigrationRecord[]> {
|
|
||||||
await this.ensureMigrationsTable(tenantKnex);
|
|
||||||
|
|
||||||
let query = tenantKnex('custom_migrations').where({ tenantId });
|
|
||||||
|
|
||||||
if (filter?.status) {
|
|
||||||
query = query.where({ status: filter.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter?.type) {
|
|
||||||
query = query.where({ type: filter.type });
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.orderBy('created_at', 'asc');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { CustomMigrationService } from './custom-migration.service';
|
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TenantModule],
|
|
||||||
providers: [CustomMigrationService],
|
|
||||||
exports: [CustomMigrationService],
|
|
||||||
})
|
|
||||||
export class MigrationModule {}
|
|
||||||
@@ -1,38 +1,7 @@
|
|||||||
import { Model, ModelOptions, QueryContext } from 'objection';
|
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
|
||||||
|
|
||||||
export class BaseModel extends Model {
|
export class BaseModel extends Model {
|
||||||
/**
|
static columnNameMappers = snakeCaseMappers();
|
||||||
* Use a minimal column mapper: keep property names as-is, but handle
|
|
||||||
* timestamp fields that are stored as created_at/updated_at in the DB.
|
|
||||||
*/
|
|
||||||
static columnNameMappers = {
|
|
||||||
parse(dbRow: Record<string, any>) {
|
|
||||||
const mapped: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(dbRow || {})) {
|
|
||||||
if (key === 'created_at') {
|
|
||||||
mapped.createdAt = value;
|
|
||||||
} else if (key === 'updated_at') {
|
|
||||||
mapped.updatedAt = value;
|
|
||||||
} else {
|
|
||||||
mapped[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
},
|
|
||||||
format(model: Record<string, any>) {
|
|
||||||
const mapped: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(model || {})) {
|
|
||||||
if (key === 'createdAt') {
|
|
||||||
mapped.created_at = value;
|
|
||||||
} else if (key === 'updatedAt') {
|
|
||||||
mapped.updated_at = value;
|
|
||||||
} else {
|
|
||||||
mapped[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -30,8 +30,6 @@ export interface UIMetadata {
|
|||||||
step?: number; // For number
|
step?: number; // For number
|
||||||
accept?: string; // For file/image
|
accept?: string; // For file/image
|
||||||
relationDisplayField?: string; // Which field to display for relations
|
relationDisplayField?: string; // Which field to display for relations
|
||||||
relationObjects?: string[]; // For polymorphic relations
|
|
||||||
relationTypeField?: string; // Field API name storing the selected relation type
|
|
||||||
|
|
||||||
// Formatting
|
// Formatting
|
||||||
format?: string; // Date format, number format, etc.
|
format?: string; // Date format, number format, etc.
|
||||||
@@ -76,13 +74,5 @@ export class FieldDefinition extends BaseModel {
|
|||||||
to: 'object_definitions.id',
|
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ export class ObjectDefinition extends BaseModel {
|
|||||||
description?: string;
|
description?: string;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
isCustom: boolean;
|
isCustom: boolean;
|
||||||
orgWideDefault: 'private' | 'public_read' | 'public_read_write';
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
fields?: any[];
|
|
||||||
rolePermissions?: any[];
|
|
||||||
|
|
||||||
static get jsonSchema() {
|
static get jsonSchema() {
|
||||||
return {
|
return {
|
||||||
@@ -28,14 +25,12 @@ export class ObjectDefinition extends BaseModel {
|
|||||||
description: { type: 'string' },
|
description: { type: 'string' },
|
||||||
isSystem: { type: 'boolean' },
|
isSystem: { type: 'boolean' },
|
||||||
isCustom: { type: 'boolean' },
|
isCustom: { type: 'boolean' },
|
||||||
orgWideDefault: { type: 'string', enum: ['private', 'public_read', 'public_read_write'] },
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const { FieldDefinition } = require('./field-definition.model');
|
const { FieldDefinition } = require('./field-definition.model');
|
||||||
const { RoleObjectPermission } = require('./role-object-permission.model');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -46,14 +41,6 @@ export class ObjectDefinition extends BaseModel {
|
|||||||
to: 'field_definitions.objectDefinitionId',
|
to: 'field_definitions.objectDefinitionId',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rolePermissions: {
|
|
||||||
relation: BaseModel.HasManyRelation,
|
|
||||||
modelClass: RoleObjectPermission,
|
|
||||||
join: {
|
|
||||||
from: 'object_definitions.id',
|
|
||||||
to: 'role_object_permissions.objectDefinitionId',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,6 @@ export class Role extends BaseModel {
|
|||||||
const { RolePermission } = require('./role-permission.model');
|
const { RolePermission } = require('./role-permission.model');
|
||||||
const { Permission } = require('./permission.model');
|
const { Permission } = require('./permission.model');
|
||||||
const { User } = require('./user.model');
|
const { User } = require('./user.model');
|
||||||
const { RoleObjectPermission } = require('./role-object-permission.model');
|
|
||||||
const { RoleFieldPermission } = require('./role-field-permission.model');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rolePermissions: {
|
rolePermissions: {
|
||||||
@@ -63,22 +61,6 @@ export class Role extends BaseModel {
|
|||||||
to: 'users.id',
|
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BaseModel } from './base.model';
|
import { BaseModel } from './base.model';
|
||||||
import { ModelOptions, QueryContext } from 'objection';
|
|
||||||
|
|
||||||
export class User extends BaseModel {
|
export class User extends BaseModel {
|
||||||
static tableName = 'users';
|
static tableName = 'users';
|
||||||
@@ -9,8 +8,6 @@ export class User extends BaseModel {
|
|||||||
password: string;
|
password: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
alias?: string;
|
|
||||||
name?: string;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -25,37 +22,11 @@ export class User extends BaseModel {
|
|||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
firstName: { type: 'string' },
|
firstName: { type: 'string' },
|
||||||
lastName: { type: 'string' },
|
lastName: { type: 'string' },
|
||||||
alias: { type: 'string' },
|
|
||||||
name: { type: 'string' },
|
|
||||||
isActive: { type: 'boolean' },
|
isActive: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the `name` column before insert/update so lookup fields
|
|
||||||
* referencing User.name always have a value.
|
|
||||||
*/
|
|
||||||
private computeName() {
|
|
||||||
if (this.alias) {
|
|
||||||
this.name = this.alias;
|
|
||||||
} else if (this.firstName || this.lastName) {
|
|
||||||
this.name = [this.firstName, this.lastName].filter(Boolean).join(' ');
|
|
||||||
} else if (this.email) {
|
|
||||||
this.name = this.email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$beforeInsert(queryContext: QueryContext) {
|
|
||||||
super.$beforeInsert(queryContext);
|
|
||||||
this.computeName();
|
|
||||||
}
|
|
||||||
|
|
||||||
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
|
||||||
super.$beforeUpdate(opt, queryContext);
|
|
||||||
this.computeName();
|
|
||||||
}
|
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const { UserRole } = require('./user-role.model');
|
const { UserRole } = require('./user-role.model');
|
||||||
const { Role } = require('./role.model');
|
const { Role } = require('./role.model');
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ export interface FieldConfigDTO {
|
|||||||
step?: number;
|
step?: number;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
relationObject?: string;
|
relationObject?: string;
|
||||||
relationObjects?: string[];
|
|
||||||
relationDisplayField?: string;
|
relationDisplayField?: string;
|
||||||
relationTypeField?: string;
|
|
||||||
format?: string;
|
format?: string;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
@@ -45,14 +43,6 @@ export interface ObjectDefinitionDTO {
|
|||||||
description?: string;
|
description?: string;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
fields: FieldConfigDTO[];
|
fields: FieldConfigDTO[];
|
||||||
relatedLists?: Array<{
|
|
||||||
title: string;
|
|
||||||
relationName: string;
|
|
||||||
objectApiName: string;
|
|
||||||
fields: FieldConfigDTO[];
|
|
||||||
canCreate?: boolean;
|
|
||||||
createRoute?: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -61,33 +51,13 @@ export class FieldMapperService {
|
|||||||
* Convert a field definition from the database to a frontend-friendly FieldConfig
|
* Convert a field definition from the database to a frontend-friendly FieldConfig
|
||||||
*/
|
*/
|
||||||
mapFieldToDTO(field: any): FieldConfigDTO {
|
mapFieldToDTO(field: any): FieldConfigDTO {
|
||||||
// Parse ui_metadata if it's a JSON string or object
|
const uiMetadata = field.uiMetadata || {};
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: field.id,
|
id: field.id,
|
||||||
apiName: field.apiName,
|
apiName: field.apiName,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
type: frontendType,
|
type: this.mapFieldType(field.type),
|
||||||
|
|
||||||
// Display properties
|
// Display properties
|
||||||
placeholder: uiMetadata.placeholder || field.description,
|
placeholder: uiMetadata.placeholder || field.description,
|
||||||
@@ -99,7 +69,7 @@ export class FieldMapperService {
|
|||||||
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
|
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
|
||||||
|
|
||||||
// View visibility
|
// View visibility
|
||||||
showOnList: uiMetadata.showOnList !== undefined ? uiMetadata.showOnList : defaultShowOnList,
|
showOnList: uiMetadata.showOnList !== false,
|
||||||
showOnDetail: uiMetadata.showOnDetail !== false,
|
showOnDetail: uiMetadata.showOnDetail !== false,
|
||||||
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
|
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
|
||||||
sortable: uiMetadata.sortable !== false,
|
sortable: uiMetadata.sortable !== false,
|
||||||
@@ -112,12 +82,7 @@ export class FieldMapperService {
|
|||||||
step: uiMetadata.step,
|
step: uiMetadata.step,
|
||||||
accept: uiMetadata.accept,
|
accept: uiMetadata.accept,
|
||||||
relationObject: field.referenceObject,
|
relationObject: field.referenceObject,
|
||||||
relationObjects: uiMetadata.relationObjects,
|
relationDisplayField: uiMetadata.relationDisplayField,
|
||||||
// For lookup fields, provide default display field if not specified
|
|
||||||
relationDisplayField: isLookupField
|
|
||||||
? (uiMetadata.relationDisplayField || 'name')
|
|
||||||
: uiMetadata.relationDisplayField,
|
|
||||||
relationTypeField: uiMetadata.relationTypeField,
|
|
||||||
|
|
||||||
// Formatting
|
// Formatting
|
||||||
format: uiMetadata.format,
|
format: uiMetadata.format,
|
||||||
@@ -145,14 +110,12 @@ export class FieldMapperService {
|
|||||||
'boolean': 'boolean',
|
'boolean': 'boolean',
|
||||||
'date': 'date',
|
'date': 'date',
|
||||||
'datetime': 'datetime',
|
'datetime': 'datetime',
|
||||||
'date_time': 'datetime',
|
|
||||||
'time': 'time',
|
'time': 'time',
|
||||||
'email': 'email',
|
'email': 'email',
|
||||||
'url': 'url',
|
'url': 'url',
|
||||||
'phone': 'text',
|
'phone': 'text',
|
||||||
'picklist': 'select',
|
'picklist': 'select',
|
||||||
'multipicklist': 'multiSelect',
|
'multipicklist': 'multiSelect',
|
||||||
'multi_picklist': 'multiSelect',
|
|
||||||
'lookup': 'belongsTo',
|
'lookup': 'belongsTo',
|
||||||
'master-detail': 'belongsTo',
|
'master-detail': 'belongsTo',
|
||||||
'currency': 'currency',
|
'currency': 'currency',
|
||||||
@@ -224,17 +187,6 @@ export class FieldMapperService {
|
|||||||
.filter((f: any) => f.isActive !== false)
|
.filter((f: any) => f.isActive !== false)
|
||||||
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
|
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
|
||||||
.map((f: any) => this.mapFieldToDTO(f)),
|
.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,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Model } from 'objection';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base model for all dynamic and system models
|
|
||||||
* Provides common functionality for all objects
|
|
||||||
*/
|
|
||||||
export class BaseModel extends Model {
|
|
||||||
// Common fields
|
|
||||||
id?: string;
|
|
||||||
tenantId?: string;
|
|
||||||
ownerId?: string;
|
|
||||||
name?: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
|
|
||||||
// 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', ' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async $beforeUpdate() {
|
|
||||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
|
|
||||||
import { BaseModel } from './base.model';
|
|
||||||
|
|
||||||
export interface FieldDefinition {
|
|
||||||
apiName: string;
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
isRequired?: boolean;
|
|
||||||
isUnique?: boolean;
|
|
||||||
referenceObject?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RelationDefinition {
|
|
||||||
name: string;
|
|
||||||
type: 'belongsTo' | 'hasMany' | 'hasManyThrough';
|
|
||||||
targetObjectApiName: string;
|
|
||||||
fromColumn: string;
|
|
||||||
toColumn: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectMetadata {
|
|
||||||
apiName: string;
|
|
||||||
tableName: string;
|
|
||||||
fields: FieldDefinition[];
|
|
||||||
relations?: RelationDefinition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
const { tableName, fields, apiName, relations = [] } = meta;
|
|
||||||
|
|
||||||
// Build JSON schema properties
|
|
||||||
const properties: Record<string, any> = {
|
|
||||||
id: { type: 'string' },
|
|
||||||
tenantId: { type: 'string' },
|
|
||||||
ownerId: { type: 'string' },
|
|
||||||
name: { type: 'string' },
|
|
||||||
created_at: { type: 'string', format: 'date-time' },
|
|
||||||
updated_at: { type: 'string', format: 'date-time' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't require id or tenantId - they'll be set automatically
|
|
||||||
const required: string[] = [];
|
|
||||||
|
|
||||||
// Add custom fields
|
|
||||||
for (const field of fields) {
|
|
||||||
properties[field.apiName] = this.fieldToJsonSchema(field);
|
|
||||||
|
|
||||||
// Only mark as required if explicitly required AND not a system field
|
|
||||||
const systemFields = ['id', 'tenantId', 'ownerId', 'name', 'created_at', 'updated_at'];
|
|
||||||
if (field.isRequired && !systemFields.includes(field.apiName)) {
|
|
||||||
required.push(field.apiName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build relation mappings from lookup fields
|
|
||||||
const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
|
|
||||||
|
|
||||||
// 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 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 get jsonSchema() {
|
|
||||||
return {
|
|
||||||
type: 'object',
|
|
||||||
required,
|
|
||||||
properties,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DynamicModel as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a field definition to JSON schema property
|
|
||||||
*/
|
|
||||||
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
|
|
||||||
const baseSchema = () => {
|
|
||||||
switch (field.type.toUpperCase()) {
|
|
||||||
case 'TEXT':
|
|
||||||
case 'STRING':
|
|
||||||
case 'EMAIL':
|
|
||||||
case 'URL':
|
|
||||||
case 'PHONE':
|
|
||||||
case 'PICKLIST':
|
|
||||||
case 'MULTI_PICKLIST':
|
|
||||||
return {
|
|
||||||
type: 'string',
|
|
||||||
...(field.isUnique && { uniqueItems: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'LONG_TEXT':
|
|
||||||
return { type: 'string' };
|
|
||||||
|
|
||||||
case 'NUMBER':
|
|
||||||
case 'DECIMAL':
|
|
||||||
case 'CURRENCY':
|
|
||||||
case 'PERCENT':
|
|
||||||
return {
|
|
||||||
type: 'number',
|
|
||||||
...(field.isUnique && { uniqueItems: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'INTEGER':
|
|
||||||
return {
|
|
||||||
type: 'integer',
|
|
||||||
...(field.isUnique && { uniqueItems: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'BOOLEAN':
|
|
||||||
return { type: 'boolean', default: false };
|
|
||||||
|
|
||||||
case 'DATE':
|
|
||||||
return { type: 'string', format: 'date' };
|
|
||||||
|
|
||||||
case 'DATE_TIME':
|
|
||||||
return { type: 'string', format: 'date-time' };
|
|
||||||
|
|
||||||
case 'LOOKUP':
|
|
||||||
case 'BELONGS_TO':
|
|
||||||
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 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`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ModelClass } from 'objection';
|
|
||||||
import { BaseModel } from './base.model';
|
|
||||||
import { DynamicModelFactory, ObjectMetadata } from './dynamic-model.factory';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registry to store and retrieve dynamic models
|
|
||||||
* One registry per tenant
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ModelRegistry {
|
|
||||||
private registry = new Map<string, ModelClass<BaseModel>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a model in the registry
|
|
||||||
*/
|
|
||||||
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());
|
|
||||||
if (!model) {
|
|
||||||
throw new Error(`Model for ${apiName} not found in registry`);
|
|
||||||
}
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model exists in the registry
|
|
||||||
*/
|
|
||||||
hasModel(apiName: string): boolean {
|
|
||||||
return this.registry.has(apiName) || this.registry.has(apiName.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and register a model from metadata
|
|
||||||
*/
|
|
||||||
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()),
|
|
||||||
);
|
|
||||||
this.registerModel(metadata.apiName, model);
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all registered model names
|
|
||||||
*/
|
|
||||||
getAllModelNames(): string[] {
|
|
||||||
return Array.from(this.registry.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the registry (useful for testing)
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
this.registry.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Knex } from 'knex';
|
|
||||||
import { ModelClass } from 'objection';
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ModelService {
|
|
||||||
private readonly logger = new Logger(ModelService.name);
|
|
||||||
private tenantRegistries = new Map<string, ModelRegistry>();
|
|
||||||
|
|
||||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a registry for a tenant
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
async createModelForObject(
|
|
||||||
tenantId: string,
|
|
||||||
objectMetadata: ObjectMetadata,
|
|
||||||
): Promise<ModelClass<BaseModel>> {
|
|
||||||
const registry = this.getTenantRegistry(tenantId);
|
|
||||||
const model = registry.createAndRegisterModel(objectMetadata);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Registered model for ${objectMetadata.apiName} in tenant ${tenantId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a model for a tenant and object
|
|
||||||
*/
|
|
||||||
getModel(tenantId: string, objectApiName: string): ModelClass<BaseModel> {
|
|
||||||
const registry = this.getTenantRegistry(tenantId);
|
|
||||||
return registry.getModel(objectApiName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a bound model (with knex connection) for a tenant and object
|
|
||||||
*/
|
|
||||||
async getBoundModel(
|
|
||||||
tenantId: string,
|
|
||||||
objectApiName: string,
|
|
||||||
): 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a model exists for a tenant
|
|
||||||
*/
|
|
||||||
hasModel(tenantId: string, objectApiName: string): boolean {
|
|
||||||
const registry = this.getTenantRegistry(tenantId);
|
|
||||||
return registry.hasModel(objectApiName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all model names for a tenant
|
|
||||||
*/
|
|
||||||
getAllModelNames(tenantId: string): string[] {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,23 +5,11 @@ import { SetupObjectController } from './setup-object.controller';
|
|||||||
import { SchemaManagementService } from './schema-management.service';
|
import { SchemaManagementService } from './schema-management.service';
|
||||||
import { FieldMapperService } from './field-mapper.service';
|
import { FieldMapperService } from './field-mapper.service';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
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';
|
|
||||||
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule, KnowledgeModule],
|
imports: [TenantModule],
|
||||||
providers: [
|
providers: [ObjectService, SchemaManagementService, FieldMapperService],
|
||||||
ObjectService,
|
|
||||||
SchemaManagementService,
|
|
||||||
FieldMapperService,
|
|
||||||
ModelRegistry,
|
|
||||||
ModelService,
|
|
||||||
],
|
|
||||||
controllers: [RuntimeObjectController, SetupObjectController],
|
controllers: [RuntimeObjectController, SetupObjectController],
|
||||||
exports: [ObjectService, SchemaManagementService, FieldMapperService, ModelService],
|
exports: [ObjectService, SchemaManagementService, FieldMapperService],
|
||||||
})
|
})
|
||||||
export class ObjectModule {}
|
export class ObjectModule {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,49 +95,4 @@ export class RuntimeObjectController {
|
|||||||
user.userId,
|
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Direct filter-based search — used when applying a saved list view.
|
|
||||||
* Bypasses the AI planning step; accepts pre-resolved structured filters.
|
|
||||||
*/
|
|
||||||
@Post(':objectApiName/records/search')
|
|
||||||
async searchRecords(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Param('objectApiName') objectApiName: string,
|
|
||||||
@CurrentUser() user: any,
|
|
||||||
@Body() body: {
|
|
||||||
filters?: Array<{ field: string; operator: string; value?: any; values?: any[]; from?: string; to?: string }>;
|
|
||||||
sort?: { field: string; direction: 'asc' | 'desc' } | null;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const page = Number.isFinite(Number(body?.page)) ? Number(body.page) : 1;
|
|
||||||
const pageSize = Number.isFinite(Number(body?.pageSize)) ? Number(body.pageSize) : 25;
|
|
||||||
|
|
||||||
return this.objectService.searchRecordsWithFilters(
|
|
||||||
tenantId,
|
|
||||||
objectApiName,
|
|
||||||
user.userId,
|
|
||||||
body?.filters || [],
|
|
||||||
{ page, pageSize },
|
|
||||||
body?.sort || undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ export class SchemaManagementService {
|
|||||||
objectDefinition: ObjectDefinition,
|
objectDefinition: ObjectDefinition,
|
||||||
fields: FieldDefinition[],
|
fields: FieldDefinition[],
|
||||||
) {
|
) {
|
||||||
const tableName = this.getTableName(
|
const tableName = this.getTableName(objectDefinition.apiName);
|
||||||
objectDefinition.apiName,
|
|
||||||
objectDefinition.label,
|
|
||||||
objectDefinition.pluralLabel,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if table already exists
|
// Check if table already exists
|
||||||
const exists = await knex.schema.hasTable(tableName);
|
const exists = await knex.schema.hasTable(tableName);
|
||||||
@@ -48,10 +44,8 @@ export class SchemaManagementService {
|
|||||||
knex: Knex,
|
knex: Knex,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
field: FieldDefinition,
|
field: FieldDefinition,
|
||||||
objectLabel?: string,
|
|
||||||
pluralLabel?: string,
|
|
||||||
) {
|
) {
|
||||||
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
await knex.schema.alterTable(tableName, (table) => {
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
this.addFieldColumn(table, field);
|
this.addFieldColumn(table, field);
|
||||||
@@ -67,10 +61,8 @@ export class SchemaManagementService {
|
|||||||
knex: Knex,
|
knex: Knex,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
fieldApiName: 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) => {
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
table.dropColumn(fieldApiName);
|
table.dropColumn(fieldApiName);
|
||||||
@@ -79,44 +71,11 @@ export class SchemaManagementService {
|
|||||||
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
|
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
|
* Drop an object table
|
||||||
*/
|
*/
|
||||||
async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
|
async dropObjectTable(knex: Knex, objectApiName: string) {
|
||||||
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
await knex.schema.dropTableIfExists(tableName);
|
await knex.schema.dropTableIfExists(tableName);
|
||||||
|
|
||||||
@@ -135,30 +94,15 @@ export class SchemaManagementService {
|
|||||||
let column: Knex.ColumnBuilder;
|
let column: Knex.ColumnBuilder;
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
// Text types
|
|
||||||
case 'String':
|
case 'String':
|
||||||
case 'TEXT':
|
|
||||||
case 'EMAIL':
|
|
||||||
case 'PHONE':
|
|
||||||
case 'URL':
|
|
||||||
column = table.string(columnName, field.length || 255);
|
column = table.string(columnName, field.length || 255);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Text':
|
case 'Text':
|
||||||
case 'LONG_TEXT':
|
|
||||||
column = table.text(columnName);
|
column = table.text(columnName);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'PICKLIST':
|
|
||||||
case 'MULTI_PICKLIST':
|
|
||||||
column = table.string(columnName, 255);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Numeric types
|
|
||||||
case 'Number':
|
case 'Number':
|
||||||
case 'NUMBER':
|
|
||||||
case 'CURRENCY':
|
|
||||||
case 'PERCENT':
|
|
||||||
if (field.scale && field.scale > 0) {
|
if (field.scale && field.scale > 0) {
|
||||||
column = table.decimal(
|
column = table.decimal(
|
||||||
columnName,
|
columnName,
|
||||||
@@ -171,28 +115,18 @@ export class SchemaManagementService {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Boolean':
|
case 'Boolean':
|
||||||
case 'BOOLEAN':
|
|
||||||
column = table.boolean(columnName).defaultTo(false);
|
column = table.boolean(columnName).defaultTo(false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Date types
|
|
||||||
case 'Date':
|
case 'Date':
|
||||||
case 'DATE':
|
|
||||||
column = table.date(columnName);
|
column = table.date(columnName);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'DateTime':
|
case 'DateTime':
|
||||||
case 'DATE_TIME':
|
|
||||||
column = table.datetime(columnName);
|
column = table.datetime(columnName);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'TIME':
|
|
||||||
column = table.time(columnName);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Relationship types
|
|
||||||
case 'Reference':
|
case 'Reference':
|
||||||
case 'LOOKUP':
|
|
||||||
column = table.uuid(columnName);
|
column = table.uuid(columnName);
|
||||||
if (field.referenceObject) {
|
if (field.referenceObject) {
|
||||||
const refTableName = this.getTableName(field.referenceObject);
|
const refTableName = this.getTableName(field.referenceObject);
|
||||||
@@ -200,30 +134,19 @@ export class SchemaManagementService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Email (legacy)
|
|
||||||
case 'Email':
|
case 'Email':
|
||||||
column = table.string(columnName, 255);
|
column = table.string(columnName, 255);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Phone (legacy)
|
|
||||||
case 'Phone':
|
case 'Phone':
|
||||||
column = table.string(columnName, 50);
|
column = table.string(columnName, 50);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Url (legacy)
|
|
||||||
case 'Url':
|
case 'Url':
|
||||||
column = table.string(columnName, 255);
|
column = table.string(columnName, 255);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// File types
|
|
||||||
case 'FILE':
|
|
||||||
case 'IMAGE':
|
|
||||||
column = table.text(columnName); // Store file path or URL
|
|
||||||
break;
|
|
||||||
|
|
||||||
// JSON
|
|
||||||
case 'Json':
|
case 'Json':
|
||||||
case 'JSON':
|
|
||||||
column = table.json(columnName);
|
column = table.json(columnName);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -251,35 +174,16 @@ export class SchemaManagementService {
|
|||||||
/**
|
/**
|
||||||
* Convert object API name to table name (convert to snake_case, pluralize)
|
* Convert object API name to table name (convert to snake_case, pluralize)
|
||||||
*/
|
*/
|
||||||
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
|
private getTableName(apiName: string): string {
|
||||||
const toSnakePlural = (source: string): string => {
|
// Convert PascalCase to snake_case
|
||||||
const cleaned = source.replace(/[\s-]+/g, '_');
|
const snakeCase = apiName
|
||||||
const snake = cleaned
|
.replace(/([A-Z])/g, '_$1')
|
||||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
.toLowerCase()
|
||||||
.replace(/__+/g, '_')
|
.replace(/^_/, '');
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^_/, '');
|
|
||||||
|
|
||||||
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
|
// Simple pluralization (append 's' if not already plural)
|
||||||
if (snake.endsWith('s')) return snake;
|
// In production, use a proper pluralization library
|
||||||
return `${snake}s`;
|
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
|
||||||
Put,
|
|
||||||
Delete,
|
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -13,7 +10,6 @@ import { ObjectService } from './object.service';
|
|||||||
import { FieldMapperService } from './field-mapper.service';
|
import { FieldMapperService } from './field-mapper.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|
||||||
|
|
||||||
@Controller('setup/objects')
|
@Controller('setup/objects')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -21,7 +17,6 @@ export class SetupObjectController {
|
|||||||
constructor(
|
constructor(
|
||||||
private objectService: ObjectService,
|
private objectService: ObjectService,
|
||||||
private fieldMapperService: FieldMapperService,
|
private fieldMapperService: FieldMapperService,
|
||||||
private tenantDbService: TenantDatabaseService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -34,8 +29,7 @@ export class SetupObjectController {
|
|||||||
@TenantId() tenantId: string,
|
@TenantId() tenantId: string,
|
||||||
@Param('objectApiName') objectApiName: string,
|
@Param('objectApiName') objectApiName: string,
|
||||||
) {
|
) {
|
||||||
const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
|
return this.objectService.getObjectDefinition(tenantId, objectApiName);
|
||||||
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':objectApiName/ui-config')
|
@Get(':objectApiName/ui-config')
|
||||||
@@ -64,93 +58,10 @@ export class SetupObjectController {
|
|||||||
@Param('objectApiName') objectApiName: string,
|
@Param('objectApiName') objectApiName: string,
|
||||||
@Body() data: any,
|
@Body() data: any,
|
||||||
) {
|
) {
|
||||||
const field = await this.objectService.createFieldDefinition(
|
return this.objectService.createFieldDefinition(
|
||||||
tenantId,
|
tenantId,
|
||||||
objectApiName,
|
objectApiName,
|
||||||
data,
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject, IsIn } from 'class-validator';
|
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
|
||||||
|
|
||||||
export type PageLayoutType = 'detail' | 'list';
|
|
||||||
|
|
||||||
export class CreatePageLayoutDto {
|
export class CreatePageLayoutDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -9,27 +7,19 @@ export class CreatePageLayoutDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
objectId: string;
|
objectId: string;
|
||||||
|
|
||||||
@IsIn(['detail', 'list'])
|
|
||||||
@IsOptional()
|
|
||||||
layoutType?: PageLayoutType = 'detail';
|
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
layoutConfig: {
|
layoutConfig: {
|
||||||
// For detail layouts: grid-based field positions
|
|
||||||
fields: Array<{
|
fields: Array<{
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
x?: number;
|
x: number;
|
||||||
y?: number;
|
y: number;
|
||||||
w?: number;
|
w: number;
|
||||||
h?: number;
|
h: number;
|
||||||
// For list layouts: field order (optional, defaults to array index)
|
|
||||||
order?: number;
|
|
||||||
}>;
|
}>;
|
||||||
relatedLists?: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -51,13 +41,11 @@ export class UpdatePageLayoutDto {
|
|||||||
layoutConfig?: {
|
layoutConfig?: {
|
||||||
fields: Array<{
|
fields: Array<{
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
x?: number;
|
x: number;
|
||||||
y?: number;
|
y: number;
|
||||||
w?: number;
|
w: number;
|
||||||
h?: number;
|
h: number;
|
||||||
order?: number;
|
|
||||||
}>;
|
}>;
|
||||||
relatedLists?: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PageLayoutService } from './page-layout.service';
|
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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
@@ -25,21 +25,13 @@ export class PageLayoutController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
|
||||||
@TenantId() tenantId: string,
|
return this.pageLayoutService.findAll(tenantId, objectId);
|
||||||
@Query('objectId') objectId?: string,
|
|
||||||
@Query('layoutType') layoutType?: PageLayoutType,
|
|
||||||
) {
|
|
||||||
return this.pageLayoutService.findAll(tenantId, objectId, layoutType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('default/:objectId')
|
@Get('default/:objectId')
|
||||||
findDefaultByObject(
|
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
|
||||||
@TenantId() tenantId: string,
|
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
|
||||||
@Param('objectId') objectId: string,
|
|
||||||
@Query('layoutType') layoutType?: PageLayoutType,
|
|
||||||
) {
|
|
||||||
return this.pageLayoutService.findDefaultByObject(tenantId, objectId, layoutType || 'detail');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
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()
|
@Injectable()
|
||||||
export class PageLayoutService {
|
export class PageLayoutService {
|
||||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
async create(tenantId: string, createDto: CreatePageLayoutDto) {
|
async create(tenantId: string, createDto: CreatePageLayoutDto) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
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) {
|
if (createDto.isDefault) {
|
||||||
await knex('page_layouts')
|
await knex('page_layouts')
|
||||||
.where({ object_id: createDto.objectId, layout_type: layoutType })
|
.where({ object_id: createDto.objectId })
|
||||||
.update({ is_default: false });
|
.update({ is_default: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [id] = await knex('page_layouts').insert({
|
const [id] = await knex('page_layouts').insert({
|
||||||
name: createDto.name,
|
name: createDto.name,
|
||||||
object_id: createDto.objectId,
|
object_id: createDto.objectId,
|
||||||
layout_type: layoutType,
|
|
||||||
is_default: createDto.isDefault || false,
|
is_default: createDto.isDefault || false,
|
||||||
layout_config: JSON.stringify(createDto.layoutConfig),
|
layout_config: JSON.stringify(createDto.layoutConfig),
|
||||||
description: createDto.description || null,
|
description: createDto.description || null,
|
||||||
@@ -31,8 +29,8 @@ export class PageLayoutService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, objectId?: string, layoutType?: PageLayoutType) {
|
async findAll(tenantId: string, objectId?: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
let query = knex('page_layouts');
|
let query = knex('page_layouts');
|
||||||
|
|
||||||
@@ -40,16 +38,12 @@ export class PageLayoutService {
|
|||||||
query = query.where({ object_id: objectId });
|
query = query.where({ object_id: objectId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layoutType) {
|
|
||||||
query = query.where({ layout_type: layoutType });
|
|
||||||
}
|
|
||||||
|
|
||||||
const layouts = await query.orderByRaw('is_default DESC, name ASC');
|
const layouts = await query.orderByRaw('is_default DESC, name ASC');
|
||||||
return layouts;
|
return layouts;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(tenantId: string, id: string) {
|
async findOne(tenantId: string, id: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const layout = await knex('page_layouts').where({ id }).first();
|
const layout = await knex('page_layouts').where({ id }).first();
|
||||||
|
|
||||||
@@ -60,26 +54,27 @@ export class PageLayoutService {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDefaultByObject(tenantId: string, objectId: string, layoutType: PageLayoutType = 'detail') {
|
async findDefaultByObject(tenantId: string, objectId: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const layout = await knex('page_layouts')
|
const layout = await knex('page_layouts')
|
||||||
.where({ object_id: objectId, is_default: true, layout_type: layoutType })
|
.where({ object_id: objectId, is_default: true })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
return layout || null;
|
return layout || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(tenantId: string, id: string, updateDto: UpdatePageLayoutDto) {
|
async update(tenantId: string, id: string, updateDto: UpdatePageLayoutDto) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
// Check if layout exists
|
// 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) {
|
if (updateDto.isDefault) {
|
||||||
|
const layout = await this.findOne(tenantId, id);
|
||||||
await knex('page_layouts')
|
await knex('page_layouts')
|
||||||
.where({ object_id: layout.object_id, layout_type: layout.layout_type })
|
.where({ object_id: layout.object_id })
|
||||||
.whereNot({ id })
|
.whereNot({ id })
|
||||||
.update({ is_default: false });
|
.update({ is_default: false });
|
||||||
}
|
}
|
||||||
@@ -112,7 +107,7 @@ export class PageLayoutService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(tenantId: string, id: string) {
|
async remove(tenantId: string, id: string) {
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { RbacService } from './rbac.service';
|
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({
|
@Module({
|
||||||
imports: [TenantModule],
|
providers: [RbacService],
|
||||||
controllers: [SetupRolesController, SetupUsersController, RecordSharingController],
|
exports: [RbacService],
|
||||||
providers: [RbacService, AbilityFactory, AuthorizationService],
|
|
||||||
exports: [RbacService, AbilityFactory, AuthorizationService],
|
|
||||||
})
|
})
|
||||||
export class RbacModule {}
|
export class RbacModule {}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +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; alias?: 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,
|
|
||||||
alias: data.alias,
|
|
||||||
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; alias?: 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;
|
|
||||||
if (data.alias !== undefined) updateData.alias = data.alias;
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user