Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cddbd09f0f | ||
|
|
20fc90a3fb | ||
|
|
51c82d3d95 | ||
|
|
a4577ddcf3 | ||
|
|
5f3fcef1ec | ||
|
|
16907aadf8 | ||
|
|
838a010fb2 | ||
|
|
be6e34914e | ||
|
|
db9848cce7 | ||
|
|
cdc202454f | ||
|
|
f4067c56b4 |
8
.env.api
8
.env.api
@@ -5,6 +5,14 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
CENTRAL_SUBDOMAINS="central,admin"
|
||||||
|
|||||||
2
.env.web
2
.env.web
@@ -2,4 +2,4 @@ NUXT_PORT=3001
|
|||||||
NUXT_HOST=0.0.0.0
|
NUXT_HOST=0.0.0.0
|
||||||
|
|
||||||
# Point Nuxt to the API container (not localhost)
|
# Point Nuxt to the API container (not localhost)
|
||||||
NUXT_PUBLIC_API_BASE_URL=http://jupiter.routebox.co:3000
|
NUXT_PUBLIC_API_BASE_URL=https://tenant1.routebox.co
|
||||||
|
|||||||
83
DEBUG_INCOMING_CALL.md
Normal file
83
DEBUG_INCOMING_CALL.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Debugging Incoming Call Issue
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
- Hear "Connecting to your call" message (TwiML is executing)
|
||||||
|
- No ring on mobile after "Connecting" message
|
||||||
|
- Click Accept button does nothing
|
||||||
|
- Call never connects
|
||||||
|
|
||||||
|
## Root Cause Hypothesis
|
||||||
|
The Twilio Device SDK is likely **NOT receiving the incoming call event** from Twilio's Signaling Server. This could be because:
|
||||||
|
|
||||||
|
1. **Identity Mismatch**: The Device's identity (from JWT token) doesn't match the `<Client>ID</Client>` in TwiML
|
||||||
|
2. **Device Not Registered**: Device registration isn't completing before the call arrives
|
||||||
|
3. **Twilio Signaling Issue**: Device isn't connected to Twilio Signaling Server
|
||||||
|
|
||||||
|
## How to Debug
|
||||||
|
|
||||||
|
### Step 1: Check Device Identity in Console
|
||||||
|
When you open the softphone dialog, **open Browser DevTools Console (F12)**
|
||||||
|
|
||||||
|
You should see logs like:
|
||||||
|
```
|
||||||
|
Token received, creating Device...
|
||||||
|
Token identity: e6d45fa3-a108-4085-81e5-a8e05e85e6fb
|
||||||
|
Token grants: {voice: {...}}
|
||||||
|
Registering Twilio Device...
|
||||||
|
✓ Twilio Device registered - ready to receive calls
|
||||||
|
Device identity: e6d45fa3-a108-4085-81e5-a8e05e85e6fb
|
||||||
|
Device state: ready
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note the Device identity value** - e.g., "e6d45fa3-a108-4085-81e5-a8e05e85e6fb"
|
||||||
|
|
||||||
|
### Step 2: Check Backend Logs
|
||||||
|
When you make an inbound call, look for backend logs showing:
|
||||||
|
|
||||||
|
```
|
||||||
|
╔════════════════════════════════════════╗
|
||||||
|
║ === INBOUND CALL RECEIVED ===
|
||||||
|
╚════════════════════════════════════════╝
|
||||||
|
...
|
||||||
|
Client IDs to dial: e6d45fa3-a108-4085-81e5-a8e05e85e6fb
|
||||||
|
First Client ID format check: "e6d45fa3-a108-4085-81e5-a8e05e85e6fb" (length: 36)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Compare Identities
|
||||||
|
The Device identity from frontend console MUST MATCH the Client ID from backend logs.
|
||||||
|
|
||||||
|
**If they match**: The issue is with Twilio Signaling or Device SDK configuration
|
||||||
|
**If they don't match**: We found the bug - identity mismatch
|
||||||
|
|
||||||
|
### Step 4: Monitor Incoming Event
|
||||||
|
When you make the inbound call, keep watching the browser console for:
|
||||||
|
|
||||||
|
```
|
||||||
|
🔔 Twilio Device INCOMING event received: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**If this appears**: The Device SDK IS receiving the call, so the Accept button issue is frontend
|
||||||
|
**If this doesn't appear**: The Device SDK is NOT receiving the call, so it's an identity/registration issue
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
- Frontend now relies on **Twilio Device SDK `incoming` event** (not Socket.IO) for showing incoming call
|
||||||
|
- Added comprehensive logging to Device initialization
|
||||||
|
- Added logging to Accept button handler
|
||||||
|
- Backend logs Device ID format for comparison
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Make an inbound call
|
||||||
|
2. Check browser console for the 5 logs above
|
||||||
|
3. Check backend logs for Client ID
|
||||||
|
4. Look for "🔔 Twilio Device INCOMING event" in browser console
|
||||||
|
5. Try clicking Accept and watch console for "📞 Accepting call" logs
|
||||||
|
6. Report back with:
|
||||||
|
- Device identity from console
|
||||||
|
- Client ID from backend logs
|
||||||
|
- Whether "🔔 Twilio Device INCOMING event" appears
|
||||||
|
- Whether any accept logs appear
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
- Backend: `/backend/src/voice/voice.controller.ts` (lines 205-210 show Client ID logging)
|
||||||
|
- Frontend: `/frontend/composables/useSoftphone.ts` (Device initialization and incoming handler)
|
||||||
173
SOFTPHONE_AI_ASSISTANT.md
Normal file
173
SOFTPHONE_AI_ASSISTANT.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Softphone AI Assistant - Complete Implementation
|
||||||
|
|
||||||
|
## 🎉 Features Implemented
|
||||||
|
|
||||||
|
### ✅ Real-time AI Call Assistant
|
||||||
|
- **OpenAI Realtime API Integration** - Listens to live calls and provides suggestions
|
||||||
|
- **Audio Streaming** - Twilio Media Streams fork audio to backend for AI processing
|
||||||
|
- **Real-time Transcription** - Speech-to-text during calls
|
||||||
|
- **Smart Suggestions** - AI analyzes conversation and advises the agent
|
||||||
|
|
||||||
|
## 🔧 Architecture
|
||||||
|
|
||||||
|
### Backend Flow
|
||||||
|
```
|
||||||
|
Inbound Call → TwiML (<Start><Stream> + <Dial>)
|
||||||
|
→ Media Stream WebSocket → OpenAI Realtime API
|
||||||
|
→ AI Processing → Socket.IO → Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
1. **TwiML Structure** (`voice.controller.ts:226-234`)
|
||||||
|
- `<Start><Stream>` - Forks audio for AI processing
|
||||||
|
- `<Dial><Client>` - Connects call to agent's softphone
|
||||||
|
|
||||||
|
2. **OpenAI Integration** (`voice.service.ts:431-519`)
|
||||||
|
- WebSocket connection to `wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01`
|
||||||
|
- Session config with custom instructions for agent assistance
|
||||||
|
- Handles transcripts and generates suggestions
|
||||||
|
|
||||||
|
3. **AI Message Handler** (`voice.service.ts:609-707`)
|
||||||
|
- Processes OpenAI events (transcripts, suggestions, audio)
|
||||||
|
- Routes suggestions to frontend via Socket.IO
|
||||||
|
- Saves transcripts to database
|
||||||
|
|
||||||
|
4. **Voice Gateway** (`voice.gateway.ts:272-289`)
|
||||||
|
- `notifyAiTranscript()` - Real-time transcript chunks
|
||||||
|
- `notifyAiSuggestion()` - AI suggestions to agent
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
1. **Softphone Dialog** (`SoftphoneDialog.vue:104-135`)
|
||||||
|
- AI Assistant section with badge showing suggestion count
|
||||||
|
- Color-coded suggestions (blue=response, green=action, purple=insight)
|
||||||
|
- Animated highlight for newest suggestion
|
||||||
|
|
||||||
|
2. **Softphone Composable** (`useSoftphone.ts:515-535`)
|
||||||
|
- Socket.IO event handlers for `ai:suggestion` and `ai:transcript`
|
||||||
|
- Maintains history of last 10 suggestions
|
||||||
|
- Maintains history of last 50 transcript items
|
||||||
|
|
||||||
|
## 📋 AI Prompt Configuration
|
||||||
|
|
||||||
|
The AI is instructed to:
|
||||||
|
- **Listen, not talk** - It advises the agent, not the caller
|
||||||
|
- **Provide concise suggestions** - 1-2 sentences max
|
||||||
|
- **Use formatted output**:
|
||||||
|
- `💡 Suggestion: [advice]`
|
||||||
|
- `⚠️ Alert: [important notice]`
|
||||||
|
- `📋 Action: [CRM action]`
|
||||||
|
|
||||||
|
## 🎨 UI Features
|
||||||
|
|
||||||
|
### Suggestion Types
|
||||||
|
- **Response** (Blue) - Suggested replies or approaches
|
||||||
|
- **Action** (Green) - Recommended CRM actions
|
||||||
|
- **Insight** (Purple) - Important alerts or observations
|
||||||
|
|
||||||
|
### Visual Feedback
|
||||||
|
- Badge showing number of suggestions
|
||||||
|
- Newest suggestion pulses for attention
|
||||||
|
- Auto-scrolling suggestion list
|
||||||
|
- Timestamp on each suggestion
|
||||||
|
|
||||||
|
## 🔍 How to Monitor
|
||||||
|
|
||||||
|
### 1. Backend Logs
|
||||||
|
```bash
|
||||||
|
# Watch for AI events
|
||||||
|
docker logs -f neo-backend-1 | grep -E "AI|OpenAI|transcript|suggestion"
|
||||||
|
```
|
||||||
|
|
||||||
|
Key log markers:
|
||||||
|
- `📝 Transcript chunk:` - Real-time speech detection
|
||||||
|
- `✅ Final transcript:` - Complete transcript saved
|
||||||
|
- `💡 AI Suggestion:` - AI-generated advice
|
||||||
|
|
||||||
|
### 2. Database
|
||||||
|
```sql
|
||||||
|
-- View call transcripts
|
||||||
|
SELECT call_sid, ai_transcript, created_at
|
||||||
|
FROM calls
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend Console
|
||||||
|
- Open browser DevTools Console
|
||||||
|
- Watch for: "AI suggestion:", "AI transcript:"
|
||||||
|
|
||||||
|
## 🚀 Testing
|
||||||
|
|
||||||
|
1. **Make a test call** to your Twilio number
|
||||||
|
2. **Accept the call** in the softphone dialog
|
||||||
|
3. **Talk during the call** - Say something like "I need to schedule a follow-up"
|
||||||
|
4. **Watch the UI** - AI suggestions appear in real-time
|
||||||
|
5. **Check logs** - See transcription and suggestion generation
|
||||||
|
|
||||||
|
## 📊 Current Status
|
||||||
|
|
||||||
|
✅ **Working**:
|
||||||
|
- Inbound calls ring softphone
|
||||||
|
- Media stream forks audio to backend
|
||||||
|
- OpenAI processes audio (1300+ packets/call)
|
||||||
|
- AI generates suggestions
|
||||||
|
- Suggestions appear in frontend
|
||||||
|
- Transcripts saved to database
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
```env
|
||||||
|
# OpenAI API Key (set in tenant integrations config)
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# Optional overrides
|
||||||
|
OPENAI_MODEL=gpt-4o-realtime-preview-2024-10-01
|
||||||
|
OPENAI_VOICE=alloy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tenant Configuration
|
||||||
|
Set in Settings > Integrations:
|
||||||
|
- OpenAI API Key
|
||||||
|
- Model (optional)
|
||||||
|
- Voice (optional)
|
||||||
|
|
||||||
|
## 🎯 Next Steps (Optional Enhancements)
|
||||||
|
|
||||||
|
1. **CRM Tool Execution** - Implement actual tool calls (search contacts, create tasks)
|
||||||
|
2. **Audio Response** - Send OpenAI audio back to caller (two-way AI interaction)
|
||||||
|
3. **Sentiment Analysis** - Track call sentiment in real-time
|
||||||
|
4. **Call Summary** - Generate post-call summary automatically
|
||||||
|
5. **Custom Prompts** - Allow agents to customize AI instructions per call type
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### No suggestions appearing?
|
||||||
|
1. Check OpenAI API key is configured
|
||||||
|
2. Verify WebSocket connection logs show "OpenAI Realtime connected"
|
||||||
|
3. Check frontend Socket.IO connection is established
|
||||||
|
4. Verify user ID matches between backend and frontend
|
||||||
|
|
||||||
|
### Transcripts not saving?
|
||||||
|
1. Check tenant database connection
|
||||||
|
2. Verify `calls` table has `ai_transcript` column
|
||||||
|
3. Check logs for "Failed to update transcript" errors
|
||||||
|
|
||||||
|
### OpenAI connection fails?
|
||||||
|
1. Verify API key is valid
|
||||||
|
2. Check model name is correct
|
||||||
|
3. Review WebSocket close codes in logs
|
||||||
|
|
||||||
|
## 📝 Files Modified
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `/backend/src/voice/voice.service.ts` - OpenAI integration & AI message handling
|
||||||
|
- `/backend/src/voice/voice.controller.ts` - TwiML generation with stream fork
|
||||||
|
- `/backend/src/voice/voice.gateway.ts` - Socket.IO event emission
|
||||||
|
- `/backend/src/main.ts` - Media stream WebSocket handler
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `/frontend/components/SoftphoneDialog.vue` - AI suggestions UI
|
||||||
|
- `/frontend/composables/useSoftphone.ts` - Socket.IO event handlers
|
||||||
@@ -18,3 +18,6 @@ JWT_EXPIRES_IN="7d"
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV="development"
|
NODE_ENV="development"
|
||||||
PORT="3000"
|
PORT="3000"
|
||||||
|
|
||||||
|
# Central Admin Subdomains (comma-separated list of subdomains that access the central database)
|
||||||
|
CENTRAL_SUBDOMAINS="central,admin"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.createTable('custom_migrations', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('tenantId').notNullable();
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.enum('type', [
|
||||||
|
'create_table',
|
||||||
|
'add_column',
|
||||||
|
'alter_column',
|
||||||
|
'add_index',
|
||||||
|
'drop_table',
|
||||||
|
'custom',
|
||||||
|
]).notNullable();
|
||||||
|
table.text('sql').notNullable();
|
||||||
|
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
|
||||||
|
table.timestamp('executedAt').nullable();
|
||||||
|
table.text('error').nullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.index(['tenantId']);
|
||||||
|
table.index(['status']);
|
||||||
|
table.index(['created_at']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.dropTableIfExists('custom_migrations');
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.table('object_definitions', (table) => {
|
||||||
|
table.string('nameField', 255).comment('API name of the field to use as record display name');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.table('object_definitions', (table) => {
|
||||||
|
table.dropColumn('nameField');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.table('object_definitions', (table) => {
|
||||||
|
table.uuid('app_id').nullable()
|
||||||
|
.comment('Optional: App that this object belongs to');
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('app_id')
|
||||||
|
.references('id')
|
||||||
|
.inTable('apps')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
|
||||||
|
table.index(['app_id']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.table('object_definitions', (table) => {
|
||||||
|
table.dropForeign('app_id');
|
||||||
|
table.dropIndex('app_id');
|
||||||
|
table.dropColumn('app_id');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.createTable('page_layouts', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('name').notNullable();
|
||||||
|
table.uuid('object_id').notNullable();
|
||||||
|
table.boolean('is_default').defaultTo(false);
|
||||||
|
table.json('layout_config').notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
// Foreign key to object_definitions
|
||||||
|
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
|
||||||
|
|
||||||
|
// Index for faster lookups
|
||||||
|
table.index(['object_id', 'is_default']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.dropTable('page_layouts');
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
// Add orgWideDefault to object_definitions
|
||||||
|
.alterTable('object_definitions', (table) => {
|
||||||
|
table
|
||||||
|
.enum('orgWideDefault', ['private', 'public_read', 'public_read_write'])
|
||||||
|
.defaultTo('private')
|
||||||
|
.notNullable();
|
||||||
|
})
|
||||||
|
// Create role_object_permissions table
|
||||||
|
.createTable('role_object_permissions', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('roleId').notNullable();
|
||||||
|
table.uuid('objectDefinitionId').notNullable();
|
||||||
|
table.boolean('canCreate').defaultTo(false);
|
||||||
|
table.boolean('canRead').defaultTo(false);
|
||||||
|
table.boolean('canEdit').defaultTo(false);
|
||||||
|
table.boolean('canDelete').defaultTo(false);
|
||||||
|
table.boolean('canViewAll').defaultTo(false);
|
||||||
|
table.boolean('canModifyAll').defaultTo(false);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('roleId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('roles')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('objectDefinitionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('object_definitions')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.unique(['roleId', 'objectDefinitionId']);
|
||||||
|
table.index(['roleId']);
|
||||||
|
table.index(['objectDefinitionId']);
|
||||||
|
})
|
||||||
|
// Create role_field_permissions table
|
||||||
|
.createTable('role_field_permissions', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('roleId').notNullable();
|
||||||
|
table.uuid('fieldDefinitionId').notNullable();
|
||||||
|
table.boolean('canRead').defaultTo(true);
|
||||||
|
table.boolean('canEdit').defaultTo(true);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('roleId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('roles')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('fieldDefinitionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('field_definitions')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.unique(['roleId', 'fieldDefinitionId']);
|
||||||
|
table.index(['roleId']);
|
||||||
|
table.index(['fieldDefinitionId']);
|
||||||
|
})
|
||||||
|
// Create record_shares table for sharing specific records
|
||||||
|
.createTable('record_shares', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('objectDefinitionId').notNullable();
|
||||||
|
table.uuid('recordId').notNullable();
|
||||||
|
table.uuid('granteeUserId').notNullable();
|
||||||
|
table.uuid('grantedByUserId').notNullable();
|
||||||
|
table.json('accessLevel').notNullable(); // { canRead, canEdit, canDelete }
|
||||||
|
table.timestamp('expiresAt').nullable();
|
||||||
|
table.timestamp('revokedAt').nullable();
|
||||||
|
table.timestamp('createdAt').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('objectDefinitionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('object_definitions')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('granteeUserId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('grantedByUserId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.index(['objectDefinitionId', 'recordId']);
|
||||||
|
table.index(['granteeUserId']);
|
||||||
|
table.index(['expiresAt']);
|
||||||
|
table.index(['revokedAt']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.dropTableIfExists('record_shares')
|
||||||
|
.dropTableIfExists('role_field_permissions')
|
||||||
|
.dropTableIfExists('role_object_permissions')
|
||||||
|
.alterTable('object_definitions', (table) => {
|
||||||
|
table.dropColumn('orgWideDefault');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
// Create calls table for tracking voice calls
|
||||||
|
await knex.schema.createTable('calls', (table) => {
|
||||||
|
table.string('id', 36).primary();
|
||||||
|
table.string('call_sid', 100).unique().notNullable().comment('Twilio call SID');
|
||||||
|
table.enum('direction', ['inbound', 'outbound']).notNullable();
|
||||||
|
table.string('from_number', 20).notNullable();
|
||||||
|
table.string('to_number', 20).notNullable();
|
||||||
|
table.enum('status', [
|
||||||
|
'queued',
|
||||||
|
'ringing',
|
||||||
|
'in-progress',
|
||||||
|
'completed',
|
||||||
|
'busy',
|
||||||
|
'failed',
|
||||||
|
'no-answer',
|
||||||
|
'canceled'
|
||||||
|
]).notNullable().defaultTo('queued');
|
||||||
|
table.integer('duration_seconds').unsigned().nullable();
|
||||||
|
table.string('recording_url', 500).nullable();
|
||||||
|
table.text('ai_transcript').nullable().comment('Full transcript from OpenAI');
|
||||||
|
table.text('ai_summary').nullable().comment('AI-generated summary');
|
||||||
|
table.json('ai_insights').nullable().comment('Structured insights from AI');
|
||||||
|
table.string('user_id', 36).notNullable().comment('User who handled the call');
|
||||||
|
table.timestamp('started_at').nullable();
|
||||||
|
table.timestamp('ended_at').nullable();
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
table.index('call_sid');
|
||||||
|
table.index('user_id');
|
||||||
|
table.index('status');
|
||||||
|
table.index('direction');
|
||||||
|
table.index(['created_at', 'user_id']);
|
||||||
|
|
||||||
|
// Foreign key to users table
|
||||||
|
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Created calls table');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.dropTableIfExists('calls');
|
||||||
|
console.log('✅ Dropped calls table');
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
await knex.schema.createTable('contacts', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('firstName', 100).notNullable();
|
||||||
|
table.string('lastName', 100).notNullable();
|
||||||
|
table.uuid('accountId').notNullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('accountId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('accounts')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.index(['accountId']);
|
||||||
|
table.index(['lastName', 'firstName']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('contact_details', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('relatedObjectType', 100).notNullable();
|
||||||
|
table.uuid('relatedObjectId').notNullable();
|
||||||
|
table.string('detailType', 50).notNullable();
|
||||||
|
table.string('label', 100);
|
||||||
|
table.text('value').notNullable();
|
||||||
|
table.boolean('isPrimary').defaultTo(false);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.index(['relatedObjectType', 'relatedObjectId']);
|
||||||
|
table.index(['detailType']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [contactObjectId] = await knex('object_definitions').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
apiName: 'Contact',
|
||||||
|
label: 'Contact',
|
||||||
|
pluralLabel: 'Contacts',
|
||||||
|
description: 'Standard Contact object',
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactObjectDefId =
|
||||||
|
contactObjectId ||
|
||||||
|
(await knex('object_definitions').where('apiName', 'Contact').first()).id;
|
||||||
|
|
||||||
|
await knex('field_definitions').insert([
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactObjectDefId,
|
||||||
|
apiName: 'firstName',
|
||||||
|
label: 'First Name',
|
||||||
|
type: 'String',
|
||||||
|
length: 100,
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 1,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactObjectDefId,
|
||||||
|
apiName: 'lastName',
|
||||||
|
label: 'Last Name',
|
||||||
|
type: 'String',
|
||||||
|
length: 100,
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 2,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactObjectDefId,
|
||||||
|
apiName: 'accountId',
|
||||||
|
label: 'Account',
|
||||||
|
type: 'Reference',
|
||||||
|
referenceObject: 'Account',
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 3,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [contactDetailObjectId] = await knex('object_definitions').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
apiName: 'ContactDetail',
|
||||||
|
label: 'Contact Detail',
|
||||||
|
pluralLabel: 'Contact Details',
|
||||||
|
description: 'Polymorphic contact detail object',
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactDetailObjectDefId =
|
||||||
|
contactDetailObjectId ||
|
||||||
|
(await knex('object_definitions').where('apiName', 'ContactDetail').first())
|
||||||
|
.id;
|
||||||
|
|
||||||
|
const contactDetailRelationObjects = ['Account', 'Contact']
|
||||||
|
|
||||||
|
await knex('field_definitions').insert([
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactDetailObjectDefId,
|
||||||
|
apiName: 'relatedObjectType',
|
||||||
|
label: 'Related Object Type',
|
||||||
|
type: 'PICKLIST',
|
||||||
|
length: 100,
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: false,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 1,
|
||||||
|
ui_metadata: JSON.stringify({
|
||||||
|
options: contactDetailRelationObjects.map((value) => ({ label: value, value })),
|
||||||
|
}),
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactDetailObjectDefId,
|
||||||
|
apiName: 'relatedObjectId',
|
||||||
|
label: 'Related Object ID',
|
||||||
|
type: 'LOOKUP',
|
||||||
|
length: 36,
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: false,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 2,
|
||||||
|
ui_metadata: JSON.stringify({
|
||||||
|
relationObjects: contactDetailRelationObjects,
|
||||||
|
relationTypeField: 'relatedObjectType',
|
||||||
|
relationDisplayField: 'name',
|
||||||
|
}),
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactDetailObjectDefId,
|
||||||
|
apiName: 'detailType',
|
||||||
|
label: 'Detail Type',
|
||||||
|
type: 'String',
|
||||||
|
length: 50,
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: false,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 3,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactDetailObjectDefId,
|
||||||
|
apiName: 'label',
|
||||||
|
label: 'Label',
|
||||||
|
type: 'String',
|
||||||
|
length: 100,
|
||||||
|
isSystem: false,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 4,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactDetailObjectDefId,
|
||||||
|
apiName: 'value',
|
||||||
|
label: 'Value',
|
||||||
|
type: 'Text',
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: false,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 5,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactDetailObjectDefId,
|
||||||
|
apiName: 'isPrimary',
|
||||||
|
label: 'Primary',
|
||||||
|
type: 'Boolean',
|
||||||
|
isSystem: false,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 6,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.dropTableIfExists('contact_details');
|
||||||
|
await knex.schema.dropTableIfExists('contacts');
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
const contactDetailObject = await knex('object_definitions')
|
||||||
|
.where({ apiName: 'ContactDetail' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!contactDetailObject) return;
|
||||||
|
|
||||||
|
const relationObjects = ['Account', 'Contact'];
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: contactDetailObject.id,
|
||||||
|
apiName: 'relatedObjectType',
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
type: 'PICKLIST',
|
||||||
|
length: 100,
|
||||||
|
isSystem: false,
|
||||||
|
ui_metadata: JSON.stringify({
|
||||||
|
options: relationObjects.map((value) => ({ label: value, value })),
|
||||||
|
}),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: contactDetailObject.id,
|
||||||
|
apiName: 'relatedObjectId',
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
type: 'LOOKUP',
|
||||||
|
length: 36,
|
||||||
|
isSystem: false,
|
||||||
|
ui_metadata: JSON.stringify({
|
||||||
|
relationObjects,
|
||||||
|
relationTypeField: 'relatedObjectType',
|
||||||
|
relationDisplayField: 'name',
|
||||||
|
}),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.whereIn('apiName', [
|
||||||
|
'detailType',
|
||||||
|
'label',
|
||||||
|
'value',
|
||||||
|
'isPrimary',
|
||||||
|
])
|
||||||
|
.andWhere({ objectDefinitionId: contactDetailObject.id })
|
||||||
|
.update({
|
||||||
|
isSystem: false,
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
const contactDetailObject = await knex('object_definitions')
|
||||||
|
.where({ apiName: 'ContactDetail' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!contactDetailObject) return;
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: contactDetailObject.id,
|
||||||
|
apiName: 'relatedObjectType',
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
type: 'String',
|
||||||
|
length: 100,
|
||||||
|
isSystem: true,
|
||||||
|
ui_metadata: null,
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: contactDetailObject.id,
|
||||||
|
apiName: 'relatedObjectId',
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
type: 'String',
|
||||||
|
length: 36,
|
||||||
|
isSystem: true,
|
||||||
|
ui_metadata: null,
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.whereIn('apiName', [
|
||||||
|
'detailType',
|
||||||
|
'label',
|
||||||
|
'value',
|
||||||
|
'isPrimary',
|
||||||
|
])
|
||||||
|
.andWhere({ objectDefinitionId: contactDetailObject.id })
|
||||||
|
.update({
|
||||||
|
isSystem: true,
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
const contactDetailObject = await knex('object_definitions')
|
||||||
|
.where({ apiName: 'ContactDetail' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!contactDetailObject) return;
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({ objectDefinitionId: contactDetailObject.id })
|
||||||
|
.whereIn('apiName', [
|
||||||
|
'relatedObjectType',
|
||||||
|
'relatedObjectId',
|
||||||
|
'detailType',
|
||||||
|
'label',
|
||||||
|
'value',
|
||||||
|
'isPrimary',
|
||||||
|
])
|
||||||
|
.update({
|
||||||
|
isSystem: false,
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
const contactDetailObject = await knex('object_definitions')
|
||||||
|
.where({ apiName: 'ContactDetail' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!contactDetailObject) return;
|
||||||
|
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({ objectDefinitionId: contactDetailObject.id })
|
||||||
|
.whereIn('apiName', [
|
||||||
|
'relatedObjectType',
|
||||||
|
'relatedObjectId',
|
||||||
|
'detailType',
|
||||||
|
'label',
|
||||||
|
'value',
|
||||||
|
'isPrimary',
|
||||||
|
])
|
||||||
|
.update({
|
||||||
|
isSystem: true,
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
// Add ownerId column to contacts
|
||||||
|
await knex.schema.alterTable('contacts', (table) => {
|
||||||
|
table.uuid('ownerId');
|
||||||
|
table
|
||||||
|
.foreign('ownerId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
table.index(['ownerId']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add ownerId field definition metadata for Contact object
|
||||||
|
const contactObject = await knex('object_definitions')
|
||||||
|
.where('apiName', 'Contact')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (contactObject) {
|
||||||
|
const existingField = await knex('field_definitions')
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: contactObject.id,
|
||||||
|
apiName: 'ownerId',
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existingField) {
|
||||||
|
await knex('field_definitions').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: contactObject.id,
|
||||||
|
apiName: 'ownerId',
|
||||||
|
label: 'Owner',
|
||||||
|
type: 'Reference',
|
||||||
|
referenceObject: 'User',
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 4,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
const contactObject = await knex('object_definitions')
|
||||||
|
.where('apiName', 'Contact')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (contactObject) {
|
||||||
|
await knex('field_definitions')
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: contactObject.id,
|
||||||
|
apiName: 'ownerId',
|
||||||
|
})
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex.schema.alterTable('contacts', (table) => {
|
||||||
|
table.dropForeign(['ownerId']);
|
||||||
|
table.dropColumn('ownerId');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
await knex.schema.createTable('approval_definitions', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('name', 191).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.string('triggerType', 191).notNullable();
|
||||||
|
table.string('targetObjectType', 191);
|
||||||
|
table.json('entryCriteria');
|
||||||
|
table.json('steps');
|
||||||
|
table.json('votingPolicy');
|
||||||
|
table.string('rejectionRule', 191);
|
||||||
|
table.string('materialChangePolicy', 191);
|
||||||
|
table.integer('version').notNullable().defaultTo(1);
|
||||||
|
table.boolean('isActive').notNullable().defaultTo(true);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_requests', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('definitionId', 191).notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('pending');
|
||||||
|
table.string('targetObjectType', 191).notNullable();
|
||||||
|
table.string('targetObjectId', 191).notNullable();
|
||||||
|
table.string('action', 191);
|
||||||
|
table.string('stateFrom', 191);
|
||||||
|
table.string('stateTo', 191);
|
||||||
|
table.json('fieldChanges');
|
||||||
|
table.json('snapshot');
|
||||||
|
table.string('versionHash', 191);
|
||||||
|
table.string('submittedById', 191);
|
||||||
|
table.timestamp('submittedAt');
|
||||||
|
table.string('currentStepKey', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['definitionId']);
|
||||||
|
table.index(['targetObjectType', 'targetObjectId']);
|
||||||
|
table.index(['submittedById']);
|
||||||
|
table
|
||||||
|
.foreign('definitionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_definitions')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('submittedById')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_steps', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('requestId', 191).notNullable();
|
||||||
|
table.string('stepKey', 191).notNullable();
|
||||||
|
table.string('name', 191).notNullable();
|
||||||
|
table.integer('stepOrder').notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('pending');
|
||||||
|
table.json('routing');
|
||||||
|
table.json('voting');
|
||||||
|
table.timestamp('dueAt');
|
||||||
|
table.timestamp('completedAt');
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['requestId']);
|
||||||
|
table.index(['status']);
|
||||||
|
table
|
||||||
|
.foreign('requestId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_requests')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_assignments', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('stepId', 191).notNullable();
|
||||||
|
table.string('assigneeId', 191).notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('pending');
|
||||||
|
table.text('response');
|
||||||
|
table.timestamp('respondedAt');
|
||||||
|
table.timestamp('dueAt');
|
||||||
|
table.string('reassignedFromId', 191);
|
||||||
|
table.string('delegatedById', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['stepId']);
|
||||||
|
table.index(['assigneeId']);
|
||||||
|
table.index(['status']);
|
||||||
|
table
|
||||||
|
.foreign('stepId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_steps')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('assigneeId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('RESTRICT')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('reassignedFromId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('delegatedById')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('approval_effect_logs', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('requestId', 191).notNullable();
|
||||||
|
table.string('effectKey', 191).notNullable();
|
||||||
|
table.string('status', 191).notNullable().defaultTo('success');
|
||||||
|
table.json('response');
|
||||||
|
table.timestamp('executedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.unique(['requestId', 'effectKey']);
|
||||||
|
table.index(['requestId']);
|
||||||
|
table
|
||||||
|
.foreign('requestId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('approval_requests')
|
||||||
|
.onDelete('CASCADE')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('tasks', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('title', 191).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.string('status', 191).notNullable().defaultTo('open');
|
||||||
|
table.string('priority', 191);
|
||||||
|
table.timestamp('dueAt');
|
||||||
|
table.string('assignedToId', 191);
|
||||||
|
table.string('relatedObjectType', 191);
|
||||||
|
table.string('relatedObjectId', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['assignedToId']);
|
||||||
|
table.index(['relatedObjectType', 'relatedObjectId']);
|
||||||
|
table
|
||||||
|
.foreign('assignedToId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.createTable('activity_logs', (table) => {
|
||||||
|
table.string('id', 191).primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('action', 191).notNullable();
|
||||||
|
table.string('subjectType', 191).notNullable();
|
||||||
|
table.string('subjectId', 191).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.json('properties');
|
||||||
|
table.string('causerId', 191);
|
||||||
|
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
table.index(['subjectType', 'subjectId']);
|
||||||
|
table.index(['causerId']);
|
||||||
|
table
|
||||||
|
.foreign('causerId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL')
|
||||||
|
.onUpdate('CASCADE');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.dropTableIfExists('activity_logs');
|
||||||
|
await knex.schema.dropTableIfExists('tasks');
|
||||||
|
await knex.schema.dropTableIfExists('approval_effect_logs');
|
||||||
|
await knex.schema.dropTableIfExists('approval_assignments');
|
||||||
|
await knex.schema.dropTableIfExists('approval_steps');
|
||||||
|
await knex.schema.dropTableIfExists('approval_requests');
|
||||||
|
await knex.schema.dropTableIfExists('approval_definitions');
|
||||||
|
};
|
||||||
1178
backend/package-lock.json
generated
1178
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,11 @@
|
|||||||
"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.12",
|
||||||
|
"@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",
|
||||||
@@ -33,6 +38,9 @@
|
|||||||
"@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",
|
||||||
@@ -40,12 +48,17 @@
|
|||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
|
"langchain": "^1.2.7",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `tenants` ADD COLUMN `integrationsConfig` JSON NULL;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/central"
|
output = "../node_modules/.prisma/central"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -31,6 +32,7 @@ model Tenant {
|
|||||||
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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/tenant"
|
output = "../node_modules/.prisma/tenant"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -124,6 +125,7 @@ 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")
|
||||||
|
|
||||||
@@ -144,11 +146,41 @@ model Account {
|
|||||||
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())
|
||||||
|
|||||||
@@ -1,8 +1,53 @@
|
|||||||
# Tenant Migration Scripts
|
# Tenant Migration & Admin Scripts
|
||||||
|
|
||||||
This directory contains scripts for managing database migrations across all tenants in the multi-tenant platform.
|
This directory contains scripts for managing database migrations across all tenants and creating admin users in the multi-tenant platform.
|
||||||
|
|
||||||
## Available Scripts
|
## Admin User Management
|
||||||
|
|
||||||
|
### Create Central Admin User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run create-central-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates an administrator user in the **central database**. Central admins can:
|
||||||
|
- Manage tenants (create, update, delete)
|
||||||
|
- Access platform-wide administration features
|
||||||
|
- View all tenant information
|
||||||
|
- Manage tenant provisioning
|
||||||
|
|
||||||
|
**Interactive Mode:**
|
||||||
|
```bash
|
||||||
|
npm run create-central-admin
|
||||||
|
# You will be prompted for:
|
||||||
|
# - Email
|
||||||
|
# - Password
|
||||||
|
# - First Name (optional)
|
||||||
|
# - Last Name (optional)
|
||||||
|
# - Role (admin or superadmin)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Non-Interactive Mode (using environment variables):**
|
||||||
|
```bash
|
||||||
|
EMAIL=admin@example.com PASSWORD=securepass123 FIRST_NAME=John LAST_NAME=Doe ROLE=superadmin npm run create-central-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logging In as Central Admin:**
|
||||||
|
1. Access the application using a central subdomain (e.g., `central.yourdomain.com` or `admin.yourdomain.com`)
|
||||||
|
2. Enter your central admin credentials
|
||||||
|
3. You'll be authenticated against the central database (not a tenant database)
|
||||||
|
|
||||||
|
**Note:** The system automatically detects if you're logging in from a central subdomain based on the `CENTRAL_SUBDOMAINS` environment variable (defaults to `central,admin`). No special UI or configuration is needed on the frontend.
|
||||||
|
|
||||||
|
### Create Tenant User
|
||||||
|
|
||||||
|
For creating users within a specific tenant database, use:
|
||||||
|
```bash
|
||||||
|
npm run create-tenant-user <tenant-slug>
|
||||||
|
# (Note: This script may need to be created or already exists)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Scripts
|
||||||
|
|
||||||
### 1. Create a New Migration
|
### 1. Create a New Migration
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ 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
|
||||||
|
// The dbHost will be 'db' for Docker connections or 'localhost' for local development
|
||||||
|
const dbHost = tenant.dbHost;
|
||||||
|
|
||||||
return knex({
|
return knex({
|
||||||
client: 'mysql2',
|
client: 'mysql2',
|
||||||
connection: {
|
connection: {
|
||||||
host: tenant.dbHost,
|
host: dbHost,
|
||||||
port: tenant.dbPort,
|
port: tenant.dbPort,
|
||||||
user: tenant.dbUsername,
|
user: tenant.dbUsername,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
@@ -79,7 +83,7 @@ async function migrateTenant(tenant: any): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ ${tenant.name}: Migration failed:`, error.message);
|
console.error(`❌ ${tenant.name}: Migration failed:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await tenantKnex.destroy();
|
await tenantKnex.destroy();
|
||||||
|
|||||||
181
backend/scripts/seed-default-roles.ts
Normal file
181
backend/scripts/seed-default-roles.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import * as knexLib from 'knex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Knex connection for tenant database
|
||||||
|
*/
|
||||||
|
function createKnexConnection(database: string): Knex {
|
||||||
|
return knexLib.default({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host: process.env.DB_HOST || 'db',
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306'),
|
||||||
|
user: 'root',
|
||||||
|
password: 'asjdnfqTash37faggT',
|
||||||
|
database: database,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleWithPermissions {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
objectPermissions: {
|
||||||
|
[objectApiName: string]: {
|
||||||
|
canCreate: boolean;
|
||||||
|
canRead: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canViewAll: boolean;
|
||||||
|
canModifyAll: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ROLES: RoleWithPermissions[] = [
|
||||||
|
{
|
||||||
|
name: 'System Administrator',
|
||||||
|
description: 'Full access to all objects and records. Can view and modify all data.',
|
||||||
|
objectPermissions: {
|
||||||
|
'*': {
|
||||||
|
canCreate: true,
|
||||||
|
canRead: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
canViewAll: true,
|
||||||
|
canModifyAll: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Standard User',
|
||||||
|
description: 'Can create, read, edit, and delete own records. Respects OWD settings.',
|
||||||
|
objectPermissions: {
|
||||||
|
'*': {
|
||||||
|
canCreate: true,
|
||||||
|
canRead: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
canViewAll: false,
|
||||||
|
canModifyAll: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Read Only',
|
||||||
|
description: 'Can only read records based on OWD settings. No create, edit, or delete.',
|
||||||
|
objectPermissions: {
|
||||||
|
'*': {
|
||||||
|
canCreate: false,
|
||||||
|
canRead: true,
|
||||||
|
canEdit: false,
|
||||||
|
canDelete: false,
|
||||||
|
canViewAll: false,
|
||||||
|
canModifyAll: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedRolesForTenant(knex: Knex, tenantName: string) {
|
||||||
|
console.log(`\n🌱 Seeding roles for tenant: ${tenantName}`);
|
||||||
|
|
||||||
|
// Get all object definitions
|
||||||
|
const objectDefinitions = await knex('object_definitions').select('id', 'apiName');
|
||||||
|
|
||||||
|
for (const roleData of DEFAULT_ROLES) {
|
||||||
|
// Check if role already exists
|
||||||
|
const existingRole = await knex('roles')
|
||||||
|
.where({ name: roleData.name })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
let roleId: string;
|
||||||
|
|
||||||
|
if (existingRole) {
|
||||||
|
console.log(` ℹ️ Role "${roleData.name}" already exists, skipping...`);
|
||||||
|
roleId = existingRole.id;
|
||||||
|
} else {
|
||||||
|
// Create role
|
||||||
|
await knex('roles').insert({
|
||||||
|
name: roleData.name,
|
||||||
|
guardName: 'api',
|
||||||
|
description: roleData.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the inserted role
|
||||||
|
const newRole = await knex('roles')
|
||||||
|
.where({ name: roleData.name })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
roleId = newRole.id;
|
||||||
|
console.log(` ✅ Created role: ${roleData.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create object permissions for all objects
|
||||||
|
const wildcardPermissions = roleData.objectPermissions['*'];
|
||||||
|
|
||||||
|
for (const objectDef of objectDefinitions) {
|
||||||
|
// Check if permission already exists
|
||||||
|
const existingPermission = await knex('role_object_permissions')
|
||||||
|
.where({
|
||||||
|
roleId: roleId,
|
||||||
|
objectDefinitionId: objectDef.id,
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existingPermission) {
|
||||||
|
await knex('role_object_permissions').insert({
|
||||||
|
roleId: roleId,
|
||||||
|
objectDefinitionId: objectDef.id,
|
||||||
|
canCreate: wildcardPermissions.canCreate,
|
||||||
|
canRead: wildcardPermissions.canRead,
|
||||||
|
canEdit: wildcardPermissions.canEdit,
|
||||||
|
canDelete: wildcardPermissions.canDelete,
|
||||||
|
canViewAll: wildcardPermissions.canViewAll,
|
||||||
|
canModifyAll: wildcardPermissions.canModifyAll,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` 📋 Set permissions for ${objectDefinitions.length} objects`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAllTenants() {
|
||||||
|
console.log('🚀 Starting role seeding for all tenants...\n');
|
||||||
|
|
||||||
|
// For now, seed the main tenant database
|
||||||
|
const databases = ['tenant_tenant1'];
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const database of databases) {
|
||||||
|
try {
|
||||||
|
const knex = createKnexConnection(database);
|
||||||
|
await seedRolesForTenant(knex, database);
|
||||||
|
await knex.destroy();
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ${database}: Seeding failed:`, error.message);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n============================================================');
|
||||||
|
console.log('📊 Seeding Summary');
|
||||||
|
console.log('============================================================');
|
||||||
|
console.log(`✅ Successful: ${successCount}`);
|
||||||
|
console.log(`❌ Failed: ${errorCount}`);
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
console.log('\n🎉 All tenant roles seeded successfully!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAllTenants()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Unhandled error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
72
backend/scripts/update-name-field.ts
Normal file
72
backend/scripts/update-name-field.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { getCentralPrisma } from '../src/prisma/central-prisma.service';
|
||||||
|
import * as knex from 'knex';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
function decrypt(text: string): string {
|
||||||
|
const parts = text.split(':');
|
||||||
|
const iv = Buffer.from(parts.shift()!, 'hex');
|
||||||
|
const encryptedText = Buffer.from(parts.join(':'), 'hex');
|
||||||
|
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
'aes-256-cbc',
|
||||||
|
key,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
let decrypted = decipher.update(encryptedText);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
return decrypted.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNameField() {
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find tenant1
|
||||||
|
const tenant = await centralPrisma.tenant.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: 'tenant1' },
|
||||||
|
{ slug: 'tenant1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
console.error('❌ Tenant tenant1 not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Tenant: ${tenant.name} (${tenant.slug})`);
|
||||||
|
console.log(`📊 Database: ${tenant.dbName}`);
|
||||||
|
|
||||||
|
// Decrypt password
|
||||||
|
const password = decrypt(tenant.dbPassword);
|
||||||
|
|
||||||
|
// Create connection
|
||||||
|
const tenantKnex = knex.default({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host: tenant.dbHost,
|
||||||
|
port: tenant.dbPort,
|
||||||
|
user: tenant.dbUsername,
|
||||||
|
password: password,
|
||||||
|
database: tenant.dbName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Account object
|
||||||
|
await tenantKnex('object_definitions')
|
||||||
|
.where({ apiName: 'Account' })
|
||||||
|
.update({ nameField: 'name' });
|
||||||
|
|
||||||
|
console.log('✅ Updated Account object nameField to "name"');
|
||||||
|
|
||||||
|
await tenantKnex.destroy();
|
||||||
|
await centralPrisma.$disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNameField();
|
||||||
42
backend/src/activity-log/activity-log.controller.ts
Normal file
42
backend/src/activity-log/activity-log.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ActivityLogService } from './activity-log.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('activity-log')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ActivityLogController {
|
||||||
|
constructor(private activityLogService: ActivityLogService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listActivities(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Query('subjectType') subjectType?: string,
|
||||||
|
@Query('subjectId') subjectId?: string,
|
||||||
|
@Query('causerId') causerId?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
) {
|
||||||
|
return this.activityLogService.listActivities(tenantId, {
|
||||||
|
subjectType,
|
||||||
|
subjectId,
|
||||||
|
causerId,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createActivity(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
action: string;
|
||||||
|
subjectType: string;
|
||||||
|
subjectId: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.activityLogService.logActivity(tenantId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/activity-log/activity-log.module.ts
Normal file
12
backend/src/activity-log/activity-log.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ActivityLogController } from './activity-log.controller';
|
||||||
|
import { ActivityLogService } from './activity-log.service';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
controllers: [ActivityLogController],
|
||||||
|
providers: [ActivityLogService],
|
||||||
|
exports: [ActivityLogService],
|
||||||
|
})
|
||||||
|
export class ActivityLogModule {}
|
||||||
54
backend/src/activity-log/activity-log.service.ts
Normal file
54
backend/src/activity-log/activity-log.service.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { ActivityLog } from '../models/activity-log.model';
|
||||||
|
|
||||||
|
export interface ActivityLogInput {
|
||||||
|
action: string;
|
||||||
|
subjectType: string;
|
||||||
|
subjectId: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityLogService {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logActivity(tenantId: string, input: ActivityLogInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ActivityLog.query(knex).insert({
|
||||||
|
action: input.action,
|
||||||
|
subjectType: input.subjectType,
|
||||||
|
subjectId: input.subjectId,
|
||||||
|
description: input.description,
|
||||||
|
properties: input.properties ?? null,
|
||||||
|
causerId: input.causerId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActivities(
|
||||||
|
tenantId: string,
|
||||||
|
filters: {
|
||||||
|
subjectType?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
causerId?: string;
|
||||||
|
action?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ActivityLog.query(knex)
|
||||||
|
.modify((qb) => {
|
||||||
|
if (filters.subjectType) qb.where('subjectType', filters.subjectType);
|
||||||
|
if (filters.subjectId) qb.where('subjectId', filters.subjectId);
|
||||||
|
if (filters.causerId) qb.where('causerId', filters.causerId);
|
||||||
|
if (filters.action) qb.where('action', filters.action);
|
||||||
|
})
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/ai-assistant/ai-assistant.controller.ts
Normal file
41
backend/src/ai-assistant/ai-assistant.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
import { AiAssistantService } from './ai-assistant.service';
|
||||||
|
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
||||||
|
import { AiSearchRequestDto } from './dto/ai-search.dto';
|
||||||
|
|
||||||
|
@Controller('ai')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AiAssistantController {
|
||||||
|
constructor(private readonly aiAssistantService: AiAssistantService) {}
|
||||||
|
|
||||||
|
@Post('chat')
|
||||||
|
async chat(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Body() payload: AiChatRequestDto,
|
||||||
|
) {
|
||||||
|
return this.aiAssistantService.handleChat(
|
||||||
|
tenantId,
|
||||||
|
user.userId,
|
||||||
|
payload.message,
|
||||||
|
payload.history,
|
||||||
|
payload.context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('search')
|
||||||
|
async search(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Body() payload: AiSearchRequestDto,
|
||||||
|
) {
|
||||||
|
return this.aiAssistantService.searchRecords(
|
||||||
|
tenantId,
|
||||||
|
user.userId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/ai-assistant/ai-assistant.module.ts
Normal file
14
backend/src/ai-assistant/ai-assistant.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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 {}
|
||||||
1236
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
1236
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal file
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface AiChatMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatContext {
|
||||||
|
objectApiName?: string;
|
||||||
|
view?: string;
|
||||||
|
recordId?: string;
|
||||||
|
route?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAssistantReply {
|
||||||
|
reply: string;
|
||||||
|
action?: 'create_record' | 'collect_fields' | 'clarify';
|
||||||
|
missingFields?: string[];
|
||||||
|
record?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAssistantState {
|
||||||
|
message: string;
|
||||||
|
history?: AiChatMessage[];
|
||||||
|
context: AiChatContext;
|
||||||
|
objectDefinition?: any;
|
||||||
|
pageLayout?: any;
|
||||||
|
extractedFields?: Record<string, any>;
|
||||||
|
requiredFields?: string[];
|
||||||
|
missingFields?: string[];
|
||||||
|
action?: AiAssistantReply['action'];
|
||||||
|
record?: any;
|
||||||
|
reply?: string;
|
||||||
|
}
|
||||||
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal file
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { AiChatMessageDto } from './ai-chat.message.dto';
|
||||||
|
|
||||||
|
export class AiChatContextDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
objectApiName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
view?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
recordId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
route?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiChatRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
context?: AiChatContextDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AiChatMessageDto)
|
||||||
|
history?: AiChatMessageDto[];
|
||||||
|
}
|
||||||
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal file
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class AiChatMessageDto {
|
||||||
|
@IsIn(['user', 'assistant'])
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
22
backend/src/ai-assistant/dto/ai-search.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class AiSearchRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
objectApiName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
query: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { RbacModule } from './rbac/rbac.module';
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
import { ObjectModule } from './object/object.module';
|
import { ObjectModule } from './object/object.module';
|
||||||
import { AppBuilderModule } from './app-builder/app-builder.module';
|
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||||
|
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||||
|
import { VoiceModule } from './voice/voice.module';
|
||||||
|
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
||||||
|
import { ActivityLogModule } from './activity-log/activity-log.module';
|
||||||
|
import { TaskModule } from './task/task.module';
|
||||||
|
import { ApprovalModule } from './approval/approval.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,6 +24,12 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
|
|||||||
RbacModule,
|
RbacModule,
|
||||||
ObjectModule,
|
ObjectModule,
|
||||||
AppBuilderModule,
|
AppBuilderModule,
|
||||||
|
PageLayoutModule,
|
||||||
|
VoiceModule,
|
||||||
|
AiAssistantModule,
|
||||||
|
ActivityLogModule,
|
||||||
|
TaskModule,
|
||||||
|
ApprovalModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
54
backend/src/approval/approval-request.controller.ts
Normal file
54
backend/src/approval/approval-request.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Body, Controller, Get, Patch, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('approvals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApprovalRequestController {
|
||||||
|
constructor(private approvalService: ApprovalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listRequests(@TenantId() tenantId: string) {
|
||||||
|
return this.approvalService.listRequests(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRequest(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
definitionId: string;
|
||||||
|
targetObjectType: string;
|
||||||
|
targetObjectId: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any>;
|
||||||
|
snapshot?: Record<string, any>;
|
||||||
|
submittedById?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.createRequest(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('assignments/:assignmentId')
|
||||||
|
async updateAssignmentStatus(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('assignmentId') assignmentId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
status: 'approved' | 'rejected';
|
||||||
|
response?: string;
|
||||||
|
actedById?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.updateAssignmentStatus(
|
||||||
|
tenantId,
|
||||||
|
assignmentId,
|
||||||
|
body.status,
|
||||||
|
body.response,
|
||||||
|
body.actedById,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/approval/approval-setup.controller.ts
Normal file
56
backend/src/approval/approval-setup.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('setup/approvals')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApprovalSetupController {
|
||||||
|
constructor(private approvalService: ApprovalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listDefinitions(@TenantId() tenantId: string) {
|
||||||
|
return this.approvalService.listDefinitions(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any>;
|
||||||
|
steps?: Array<Record<string, any>>;
|
||||||
|
votingPolicy?: Record<string, any>;
|
||||||
|
rejectionRule?: string;
|
||||||
|
materialChangePolicy?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.createDefinition(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':definitionId')
|
||||||
|
async updateDefinition(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('definitionId') definitionId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType?: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any>;
|
||||||
|
steps?: Array<Record<string, any>>;
|
||||||
|
votingPolicy?: Record<string, any>;
|
||||||
|
rejectionRule?: string;
|
||||||
|
materialChangePolicy?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.approvalService.updateDefinition(tenantId, definitionId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/approval/approval.module.ts
Normal file
14
backend/src/approval/approval.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ApprovalService } from './approval.service';
|
||||||
|
import { ApprovalSetupController } from './approval-setup.controller';
|
||||||
|
import { ApprovalRequestController } from './approval-request.controller';
|
||||||
|
import { ActivityLogModule } from '../activity-log/activity-log.module';
|
||||||
|
import { TaskModule } from '../task/task.module';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule, ActivityLogModule, TaskModule],
|
||||||
|
controllers: [ApprovalSetupController, ApprovalRequestController],
|
||||||
|
providers: [ApprovalService],
|
||||||
|
})
|
||||||
|
export class ApprovalModule {}
|
||||||
402
backend/src/approval/approval.service.ts
Normal file
402
backend/src/approval/approval.service.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { ActivityLogService } from '../activity-log/activity-log.service';
|
||||||
|
import { TaskService } from '../task/task.service';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { ApprovalDefinition } from '../models/approval-definition.model';
|
||||||
|
import { ApprovalRequest } from '../models/approval-request.model';
|
||||||
|
import { ApprovalStep } from '../models/approval-step.model';
|
||||||
|
import { ApprovalAssignment } from '../models/approval-assignment.model';
|
||||||
|
import { ApprovalEffectLog } from '../models/approval-effect-log.model';
|
||||||
|
|
||||||
|
interface ApprovalDefinitionInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any> | null;
|
||||||
|
steps?: Array<Record<string, any>> | null;
|
||||||
|
votingPolicy?: Record<string, any> | null;
|
||||||
|
rejectionRule?: string | null;
|
||||||
|
materialChangePolicy?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRequestInput {
|
||||||
|
definitionId: string;
|
||||||
|
targetObjectType: string;
|
||||||
|
targetObjectId: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any> | null;
|
||||||
|
snapshot?: Record<string, any> | null;
|
||||||
|
submittedById?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApprovalService {
|
||||||
|
constructor(
|
||||||
|
private tenantDbService: TenantDatabaseService,
|
||||||
|
private activityLogService: ActivityLogService,
|
||||||
|
private taskService: TaskService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDefinitions(tenantId: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ApprovalDefinition.query(knex).orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDefinition(tenantId: string, input: ApprovalDefinitionInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const definition = await ApprovalDefinition.query(knex).insert({
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
entryCriteria: input.entryCriteria ?? null,
|
||||||
|
steps: input.steps ?? null,
|
||||||
|
votingPolicy: input.votingPolicy ?? null,
|
||||||
|
rejectionRule: input.rejectionRule ?? null,
|
||||||
|
materialChangePolicy: input.materialChangePolicy ?? null,
|
||||||
|
isActive: input.isActive ?? true,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_definition.created',
|
||||||
|
subjectType: 'ApprovalDefinition',
|
||||||
|
subjectId: definition.id,
|
||||||
|
description: `Created approval definition ${definition.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDefinition(
|
||||||
|
tenantId: string,
|
||||||
|
definitionId: string,
|
||||||
|
input: Partial<ApprovalDefinitionInput>,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const existing = await ApprovalDefinition.query(knex).findById(definitionId);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Approval definition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsVersionBump =
|
||||||
|
input.entryCriteria !== undefined ||
|
||||||
|
input.steps !== undefined ||
|
||||||
|
input.votingPolicy !== undefined ||
|
||||||
|
input.rejectionRule !== undefined ||
|
||||||
|
input.materialChangePolicy !== undefined;
|
||||||
|
|
||||||
|
const definition = await ApprovalDefinition.query(knex).patchAndFetchById(definitionId, {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
entryCriteria: input.entryCriteria ?? undefined,
|
||||||
|
steps: input.steps ?? undefined,
|
||||||
|
votingPolicy: input.votingPolicy ?? undefined,
|
||||||
|
rejectionRule: input.rejectionRule ?? undefined,
|
||||||
|
materialChangePolicy: input.materialChangePolicy ?? undefined,
|
||||||
|
isActive: input.isActive,
|
||||||
|
version: needsVersionBump ? existing.version + 1 : existing.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_definition.updated',
|
||||||
|
subjectType: 'ApprovalDefinition',
|
||||||
|
subjectId: definition.id,
|
||||||
|
description: `Updated approval definition ${definition.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRequests(tenantId: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return ApprovalRequest.query(knex)
|
||||||
|
.withGraphFetched('[steps.assignments,definition]')
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRequest(tenantId: string, input: ApprovalRequestInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const definition = await ApprovalDefinition.query(knex).findById(input.definitionId);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
throw new NotFoundException('Approval definition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionHash = this.createVersionHash({
|
||||||
|
snapshot: input.snapshot ?? {},
|
||||||
|
fieldChanges: input.fieldChanges ?? {},
|
||||||
|
definitionVersion: definition.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await ApprovalRequest.query(knex).insertAndFetch({
|
||||||
|
definitionId: definition.id,
|
||||||
|
status: 'pending',
|
||||||
|
targetObjectType: input.targetObjectType,
|
||||||
|
targetObjectId: input.targetObjectId,
|
||||||
|
action: input.action,
|
||||||
|
stateFrom: input.stateFrom,
|
||||||
|
stateTo: input.stateTo,
|
||||||
|
fieldChanges: input.fieldChanges ?? null,
|
||||||
|
snapshot: input.snapshot ?? null,
|
||||||
|
versionHash,
|
||||||
|
submittedById: input.submittedById ?? null,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepConfigs = Array.isArray(definition.steps) ? definition.steps : [];
|
||||||
|
for (const [index, stepConfig] of stepConfigs.entries()) {
|
||||||
|
const stepKey = stepConfig.key || `step-${index + 1}`;
|
||||||
|
const step = await ApprovalStep.query(knex).insertAndFetch({
|
||||||
|
requestId: request.id,
|
||||||
|
stepKey,
|
||||||
|
name: stepConfig.name || `Step ${index + 1}`,
|
||||||
|
stepOrder: stepConfig.order ?? index + 1,
|
||||||
|
status: 'pending',
|
||||||
|
routing: stepConfig.routing ?? null,
|
||||||
|
voting: stepConfig.voting ?? null,
|
||||||
|
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignees: string[] = Array.isArray(stepConfig.assignees)
|
||||||
|
? stepConfig.assignees
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const assigneeId of assignees) {
|
||||||
|
const assignment = await ApprovalAssignment.query(knex).insertAndFetch({
|
||||||
|
stepId: step.id,
|
||||||
|
assigneeId,
|
||||||
|
status: 'pending',
|
||||||
|
dueAt: stepConfig.dueAt ? new Date(stepConfig.dueAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.taskService.createTask(tenantId, {
|
||||||
|
title: `Approval needed: ${definition.name}`,
|
||||||
|
description: `Approval step ${step.name} requires your review.`,
|
||||||
|
dueAt: stepConfig.dueAt ?? undefined,
|
||||||
|
assignedToId: assigneeId,
|
||||||
|
relatedObjectType: 'ApprovalAssignment',
|
||||||
|
relatedObjectId: assignment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_assignment.created',
|
||||||
|
subjectType: 'ApprovalAssignment',
|
||||||
|
subjectId: assignment.id,
|
||||||
|
description: `Assignment created for approval step ${step.name}`,
|
||||||
|
causerId: input.submittedById ?? undefined,
|
||||||
|
properties: {
|
||||||
|
requestId: request.id,
|
||||||
|
stepId: step.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: 'approval_request.created',
|
||||||
|
subjectType: 'ApprovalRequest',
|
||||||
|
subjectId: request.id,
|
||||||
|
description: `Created approval request for ${input.targetObjectType}`,
|
||||||
|
causerId: input.submittedById ?? undefined,
|
||||||
|
properties: {
|
||||||
|
definitionId: definition.id,
|
||||||
|
targetObjectId: input.targetObjectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAssignmentStatus(
|
||||||
|
tenantId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
response?: string,
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const assignment = await ApprovalAssignment.query(knex)
|
||||||
|
.findById(assignmentId)
|
||||||
|
.withGraphFetched('step.request');
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
throw new NotFoundException('Approval assignment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAssignment = await ApprovalAssignment.query(knex).patchAndFetchById(
|
||||||
|
assignmentId,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
response,
|
||||||
|
respondedAt: new Date(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.taskService.updateTaskByRelated(
|
||||||
|
tenantId,
|
||||||
|
'ApprovalAssignment',
|
||||||
|
assignmentId,
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_assignment.${status}`,
|
||||||
|
subjectType: 'ApprovalAssignment',
|
||||||
|
subjectId: assignmentId,
|
||||||
|
description: `Assignment ${status}`,
|
||||||
|
causerId: actedById ?? assignment.assigneeId,
|
||||||
|
properties: {
|
||||||
|
stepId: assignment.stepId,
|
||||||
|
requestId: assignment.step?.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.evaluateStepCompletion(
|
||||||
|
tenantId,
|
||||||
|
assignment.stepId,
|
||||||
|
actedById ?? assignment.assigneeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedAssignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateStepCompletion(tenantId: string, stepId: string, actedById?: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const step = await ApprovalStep.query(knex)
|
||||||
|
.findById(stepId)
|
||||||
|
.withGraphFetched('[assignments, request.[definition,steps]]');
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voting = (step.voting as Record<string, any>) || {};
|
||||||
|
const rule = voting.type || 'unanimous';
|
||||||
|
const rejectionRule = voting.rejectionRule || step.request?.definition?.rejectionRule || 'any';
|
||||||
|
|
||||||
|
const approvals = (step.assignments || []).filter((assignment) => assignment.status === 'approved');
|
||||||
|
const rejections = (step.assignments || []).filter((assignment) => assignment.status === 'rejected');
|
||||||
|
const totalAssignments = step.assignments?.length || 1;
|
||||||
|
|
||||||
|
if (rejections.length > 0 && rejectionRule === 'any') {
|
||||||
|
await this.completeStep(tenantId, step, 'rejected', actedById);
|
||||||
|
await this.completeRequestIfNeeded(tenantId, step.requestId, 'rejected', actedById);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = this.checkApprovalRule(rule, approvals.length, totalAssignments, voting.threshold);
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
await this.completeStep(tenantId, step, 'approved', actedById);
|
||||||
|
await this.completeRequestIfNeeded(tenantId, step.requestId, 'approved', actedById);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkApprovalRule(rule: string, approvals: number, total: number, threshold?: number) {
|
||||||
|
if (rule === 'majority') {
|
||||||
|
return approvals > total / 2;
|
||||||
|
}
|
||||||
|
if (rule === 'k-of-n') {
|
||||||
|
const required = threshold ?? total;
|
||||||
|
return approvals >= required;
|
||||||
|
}
|
||||||
|
return approvals === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeStep(
|
||||||
|
tenantId: string,
|
||||||
|
step: { id: string; status: string; requestId: string; name: string },
|
||||||
|
status: string,
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
if (step.status === status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ApprovalStep.query(knex).patchAndFetchById(step.id, {
|
||||||
|
status,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_step.${status}`,
|
||||||
|
subjectType: 'ApprovalStep',
|
||||||
|
subjectId: step.id,
|
||||||
|
description: `Step ${step.name} marked ${status}`,
|
||||||
|
causerId: actedById,
|
||||||
|
properties: { requestId: step.requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeRequestIfNeeded(
|
||||||
|
tenantId: string,
|
||||||
|
requestId: string,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
actedById?: string,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const request = await ApprovalRequest.query(knex)
|
||||||
|
.findById(requestId)
|
||||||
|
.withGraphFetched('steps');
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApproved = (request.steps || []).every((step) => step.status === 'approved');
|
||||||
|
if (status === 'rejected' || allApproved) {
|
||||||
|
const finalStatus = status === 'rejected' ? 'rejected' : 'approved';
|
||||||
|
await ApprovalRequest.query(knex).patchAndFetchById(requestId, {
|
||||||
|
status: finalStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activityLogService.logActivity(tenantId, {
|
||||||
|
action: `approval_request.${finalStatus}`,
|
||||||
|
subjectType: 'ApprovalRequest',
|
||||||
|
subjectId: requestId,
|
||||||
|
description: `Request ${finalStatus}`,
|
||||||
|
causerId: actedById,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.logEffectExecution(tenantId, requestId, `on_${finalStatus}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logEffectExecution(tenantId: string, requestId: string, effectKey: string) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
const existing = await ApprovalEffectLog.query(knex)
|
||||||
|
.where({ requestId, effectKey })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApprovalEffectLog.query(knex).insert({
|
||||||
|
requestId,
|
||||||
|
effectKey,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVersionHash(payload: Record<string, any>) {
|
||||||
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -40,17 +41,33 @@ class RegisterDto {
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
private isCentralSubdomain(subdomain: string): boolean {
|
||||||
|
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
|
||||||
|
return centralSubdomains.includes(subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
|
async login(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() loginDto: LoginDto,
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
const subdomain = req.raw?.subdomain;
|
||||||
|
|
||||||
|
|
||||||
|
// If it's a central subdomain, tenantId is not required
|
||||||
|
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new UnauthorizedException('Tenant ID is required');
|
throw new UnauthorizedException('Tenant ID is required');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.authService.validateUser(
|
const user = await this.authService.validateUser(
|
||||||
tenantId,
|
tenantId,
|
||||||
loginDto.email,
|
loginDto.email,
|
||||||
loginDto.password,
|
loginDto.password,
|
||||||
|
subdomain,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -64,10 +81,16 @@ export class AuthController {
|
|||||||
async register(
|
async register(
|
||||||
@TenantId() tenantId: string,
|
@TenantId() tenantId: string,
|
||||||
@Body() registerDto: RegisterDto,
|
@Body() registerDto: RegisterDto,
|
||||||
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
|
const subdomain = req.raw?.subdomain;
|
||||||
|
|
||||||
|
// If it's a central subdomain, tenantId is not required
|
||||||
|
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw new UnauthorizedException('Tenant ID is required');
|
throw new UnauthorizedException('Tenant ID is required');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.authService.register(
|
const user = await this.authService.register(
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -75,6 +98,7 @@ export class AuthController {
|
|||||||
registerDto.password,
|
registerDto.password,
|
||||||
registerDto.firstName,
|
registerDto.firstName,
|
||||||
registerDto.lastName,
|
registerDto.lastName,
|
||||||
|
subdomain,
|
||||||
);
|
);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -10,11 +11,24 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private isCentralSubdomain(subdomain: string): boolean {
|
||||||
|
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
|
||||||
|
return centralSubdomains.includes(subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
async validateUser(
|
async validateUser(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
subdomain?: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
|
||||||
|
// Check if this is a central subdomain
|
||||||
|
if (subdomain && this.isCentralSubdomain(subdomain)) {
|
||||||
|
return this.validateCentralUser(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, validate as tenant user
|
||||||
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const user = await tenantDb('users')
|
const user = await tenantDb('users')
|
||||||
@@ -43,6 +57,31 @@ export class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async validateCentralUser(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
const user = await centralPrisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await bcrypt.compare(password, user.password)) {
|
||||||
|
const { password: _, ...result } = user;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
isCentralAdmin: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async login(user: any) {
|
async login(user: any) {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
@@ -66,7 +105,14 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
firstName?: string,
|
firstName?: string,
|
||||||
lastName?: string,
|
lastName?: string,
|
||||||
|
subdomain?: string,
|
||||||
) {
|
) {
|
||||||
|
// Check if this is a central subdomain
|
||||||
|
if (subdomain && this.isCentralSubdomain(subdomain)) {
|
||||||
|
return this.registerCentralUser(email, password, firstName, lastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, register as tenant user
|
||||||
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
@@ -88,4 +134,28 @@ export class AuthService {
|
|||||||
const { password: _, ...result } = user;
|
const { password: _, ...result } = user;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async registerCentralUser(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
firstName?: string,
|
||||||
|
lastName?: string,
|
||||||
|
) {
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await centralPrisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: firstName || null,
|
||||||
|
lastName: lastName || null,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { password: _, ...result } = user;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import {
|
|||||||
FastifyAdapter,
|
FastifyAdapter,
|
||||||
NestFastifyApplication,
|
NestFastifyApplication,
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { VoiceService } from './voice/voice.service';
|
||||||
|
import { AudioConverterService } from './voice/audio-converter.service';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter(),
|
new FastifyAdapter({ logger: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global validation pipe
|
// Global validation pipe
|
||||||
@@ -33,6 +35,145 @@ async function bootstrap() {
|
|||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
|
// After app is listening, register WebSocket handler
|
||||||
|
const fastifyInstance = app.getHttpAdapter().getInstance();
|
||||||
|
const logger = new Logger('MediaStreamWS');
|
||||||
|
const voiceService = app.get(VoiceService);
|
||||||
|
const audioConverter = app.get(AudioConverterService);
|
||||||
|
|
||||||
|
const WebSocketServer = require('ws').Server;
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
// Handle WebSocket upgrades at the server level
|
||||||
|
const server = (fastifyInstance.server as any);
|
||||||
|
|
||||||
|
// Track active Media Streams connections: streamSid -> WebSocket
|
||||||
|
const mediaStreams: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
server.on('upgrade', (request: any, socket: any, head: any) => {
|
||||||
|
if (request.url === '/api/voice/media-stream') {
|
||||||
|
logger.log('=== MEDIA STREAM WEBSOCKET UPGRADE REQUEST ===');
|
||||||
|
logger.log(`Path: ${request.url}`);
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws: any) => {
|
||||||
|
logger.log('=== MEDIA STREAM WEBSOCKET UPGRADED SUCCESSFULLY ===');
|
||||||
|
handleMediaStreamSocket(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleMediaStreamSocket(ws: any) {
|
||||||
|
let streamSid: string | null = null;
|
||||||
|
let callSid: string | null = null;
|
||||||
|
let tenantDomain: string | null = null;
|
||||||
|
let mediaPacketCount = 0;
|
||||||
|
|
||||||
|
ws.on('message', async (message: Buffer) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(message.toString());
|
||||||
|
|
||||||
|
switch (msg.event) {
|
||||||
|
case 'connected':
|
||||||
|
logger.log('=== MEDIA STREAM EVENT: CONNECTED ===');
|
||||||
|
logger.log(`Protocol: ${msg.protocol}`);
|
||||||
|
logger.log(`Version: ${msg.version}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'start':
|
||||||
|
streamSid = msg.streamSid;
|
||||||
|
callSid = msg.start.callSid;
|
||||||
|
tenantDomain = msg.start.customParameters?.tenantId || 'tenant1';
|
||||||
|
|
||||||
|
logger.log(`=== MEDIA STREAM EVENT: START ===`);
|
||||||
|
logger.log(`StreamSid: ${streamSid}`);
|
||||||
|
logger.log(`CallSid: ${callSid}`);
|
||||||
|
logger.log(`Tenant: ${tenantDomain}`);
|
||||||
|
logger.log(`MediaFormat: ${JSON.stringify(msg.start.mediaFormat)}`);
|
||||||
|
|
||||||
|
mediaStreams.set(streamSid, ws);
|
||||||
|
logger.log(`Stored WebSocket for streamSid: ${streamSid}. Total active streams: ${mediaStreams.size}`);
|
||||||
|
|
||||||
|
// Initialize OpenAI Realtime connection
|
||||||
|
logger.log(`Initializing OpenAI Realtime for call ${callSid}...`);
|
||||||
|
try {
|
||||||
|
await voiceService.initializeOpenAIRealtime({
|
||||||
|
callSid,
|
||||||
|
tenantId: tenantDomain,
|
||||||
|
userId: msg.start.customParameters?.userId || 'system',
|
||||||
|
});
|
||||||
|
logger.log(`✓ OpenAI Realtime initialized for call ${callSid}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Failed to initialize OpenAI: ${error.message}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'media':
|
||||||
|
mediaPacketCount++;
|
||||||
|
// Only log every 500 packets to reduce noise
|
||||||
|
if (mediaPacketCount % 500 === 0) {
|
||||||
|
logger.log(`Received media packet #${mediaPacketCount} for StreamSid: ${streamSid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!callSid || !tenantDomain) {
|
||||||
|
logger.warn('Received media before start event');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert Twilio audio (μ-law 8kHz) to OpenAI format (PCM16 24kHz)
|
||||||
|
const twilioAudio = msg.media.payload;
|
||||||
|
const openaiAudio = audioConverter.twilioToOpenAI(twilioAudio);
|
||||||
|
|
||||||
|
// Send audio to OpenAI Realtime API
|
||||||
|
await voiceService.sendAudioToOpenAI(callSid, openaiAudio);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Error processing media: ${error.message}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stop':
|
||||||
|
logger.log(`=== MEDIA STREAM EVENT: STOP ===`);
|
||||||
|
logger.log(`StreamSid: ${streamSid}`);
|
||||||
|
logger.log(`Total media packets received: ${mediaPacketCount}`);
|
||||||
|
|
||||||
|
if (streamSid) {
|
||||||
|
mediaStreams.delete(streamSid);
|
||||||
|
logger.log(`Removed WebSocket for streamSid: ${streamSid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up OpenAI connection
|
||||||
|
if (callSid) {
|
||||||
|
try {
|
||||||
|
logger.log(`Cleaning up OpenAI connection for call ${callSid}...`);
|
||||||
|
await voiceService.cleanupOpenAIConnection(callSid);
|
||||||
|
logger.log(`✓ OpenAI connection cleaned up`);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Failed to cleanup OpenAI: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.debug(`Unknown media stream event: ${msg.event}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Error processing media stream message: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
logger.log(`=== MEDIA STREAM WEBSOCKET CLOSED ===`);
|
||||||
|
if (streamSid) {
|
||||||
|
mediaStreams.delete(streamSid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error: Error) => {
|
||||||
|
logger.error(`=== MEDIA STREAM WEBSOCKET ERROR ===`);
|
||||||
|
logger.error(`Error message: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
|
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
306
backend/src/migration/custom-migration.service.ts
Normal file
306
backend/src/migration/custom-migration.service.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export interface CustomMigrationRecord {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
||||||
|
sql: string;
|
||||||
|
status: 'pending' | 'executed' | 'failed';
|
||||||
|
executedAt?: Date;
|
||||||
|
error?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomMigrationService {
|
||||||
|
private readonly logger = new Logger(CustomMigrationService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SQL to create a table with standard fields
|
||||||
|
*/
|
||||||
|
generateCreateTableSQL(
|
||||||
|
tableName: string,
|
||||||
|
fields: {
|
||||||
|
apiName: string;
|
||||||
|
type: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
isUnique?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
}[] = [],
|
||||||
|
): string {
|
||||||
|
// Start with standard fields
|
||||||
|
const columns: string[] = [
|
||||||
|
'`id` VARCHAR(36) PRIMARY KEY',
|
||||||
|
'`ownerId` VARCHAR(36)',
|
||||||
|
'`name` VARCHAR(255)',
|
||||||
|
'`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
|
||||||
|
'`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add custom fields
|
||||||
|
for (const field of fields) {
|
||||||
|
const column = this.fieldToColumn(field);
|
||||||
|
columns.push(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add foreign key and index for ownerId
|
||||||
|
columns.push('INDEX `idx_owner` (`ownerId`)');
|
||||||
|
|
||||||
|
return `CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
||||||
|
${columns.join(',\n ')}
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert field definition to SQL column definition
|
||||||
|
*/
|
||||||
|
private fieldToColumn(field: {
|
||||||
|
apiName: string;
|
||||||
|
type: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
isUnique?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
}): string {
|
||||||
|
const columnName = field.apiName;
|
||||||
|
let columnDef = `\`${columnName}\``;
|
||||||
|
|
||||||
|
// Map field types to SQL types
|
||||||
|
switch (field.type.toUpperCase()) {
|
||||||
|
case 'TEXT':
|
||||||
|
case 'STRING':
|
||||||
|
columnDef += ' VARCHAR(255)';
|
||||||
|
break;
|
||||||
|
case 'LONG_TEXT':
|
||||||
|
columnDef += ' LONGTEXT';
|
||||||
|
break;
|
||||||
|
case 'NUMBER':
|
||||||
|
case 'DECIMAL':
|
||||||
|
columnDef += ' DECIMAL(18, 2)';
|
||||||
|
break;
|
||||||
|
case 'INTEGER':
|
||||||
|
columnDef += ' INT';
|
||||||
|
break;
|
||||||
|
case 'BOOLEAN':
|
||||||
|
columnDef += ' BOOLEAN DEFAULT FALSE';
|
||||||
|
break;
|
||||||
|
case 'DATE':
|
||||||
|
columnDef += ' DATE';
|
||||||
|
break;
|
||||||
|
case 'DATE_TIME':
|
||||||
|
columnDef += ' DATETIME';
|
||||||
|
break;
|
||||||
|
case 'EMAIL':
|
||||||
|
columnDef += ' VARCHAR(255)';
|
||||||
|
break;
|
||||||
|
case 'URL':
|
||||||
|
columnDef += ' VARCHAR(2048)';
|
||||||
|
break;
|
||||||
|
case 'PHONE':
|
||||||
|
columnDef += ' VARCHAR(20)';
|
||||||
|
break;
|
||||||
|
case 'CURRENCY':
|
||||||
|
columnDef += ' DECIMAL(18, 2)';
|
||||||
|
break;
|
||||||
|
case 'PERCENT':
|
||||||
|
columnDef += ' DECIMAL(5, 2)';
|
||||||
|
break;
|
||||||
|
case 'PICKLIST':
|
||||||
|
case 'MULTI_PICKLIST':
|
||||||
|
columnDef += ' VARCHAR(255)';
|
||||||
|
break;
|
||||||
|
case 'LOOKUP':
|
||||||
|
case 'BELONGS_TO':
|
||||||
|
columnDef += ' VARCHAR(36)';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
columnDef += ' VARCHAR(255)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add constraints
|
||||||
|
if (field.isRequired) {
|
||||||
|
columnDef += ' NOT NULL';
|
||||||
|
} else {
|
||||||
|
columnDef += ' NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isUnique) {
|
||||||
|
columnDef += ' UNIQUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
||||||
|
columnDef += ` DEFAULT '${field.defaultValue}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return columnDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom migration record in the database
|
||||||
|
*/
|
||||||
|
async createMigrationRecord(
|
||||||
|
tenantKnex: Knex,
|
||||||
|
data: {
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
||||||
|
sql: string;
|
||||||
|
},
|
||||||
|
): Promise<CustomMigrationRecord> {
|
||||||
|
// Ensure custom_migrations table exists
|
||||||
|
await this.ensureMigrationsTable(tenantKnex);
|
||||||
|
|
||||||
|
const id = require('crypto').randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await tenantKnex('custom_migrations').insert({
|
||||||
|
id,
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
type: data.type,
|
||||||
|
sql: data.sql,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tenantKnex('custom_migrations').where({ id }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a pending migration and update its status
|
||||||
|
*/
|
||||||
|
async executeMigration(
|
||||||
|
tenantKnex: Knex,
|
||||||
|
migrationId: string,
|
||||||
|
): Promise<CustomMigrationRecord> {
|
||||||
|
try {
|
||||||
|
// Get the migration record
|
||||||
|
const migration = await tenantKnex('custom_migrations')
|
||||||
|
.where({ id: migrationId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!migration) {
|
||||||
|
throw new Error(`Migration ${migrationId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migration.status === 'executed') {
|
||||||
|
this.logger.log(`Migration ${migrationId} already executed`);
|
||||||
|
return migration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the SQL
|
||||||
|
this.logger.log(`Executing migration: ${migration.name}`);
|
||||||
|
await tenantKnex.raw(migration.sql);
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
const now = new Date();
|
||||||
|
await tenantKnex('custom_migrations')
|
||||||
|
.where({ id: migrationId })
|
||||||
|
.update({
|
||||||
|
status: 'executed',
|
||||||
|
executedAt: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Migration ${migration.name} executed successfully`);
|
||||||
|
return tenantKnex('custom_migrations').where({ id: migrationId }).first();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to execute migration ${migrationId}:`, error);
|
||||||
|
|
||||||
|
// Update status with error
|
||||||
|
const now = new Date();
|
||||||
|
await tenantKnex('custom_migrations')
|
||||||
|
.where({ id: migrationId })
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
error: error.message,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and execute a migration in one step
|
||||||
|
*/
|
||||||
|
async createAndExecuteMigration(
|
||||||
|
tenantKnex: Knex,
|
||||||
|
tenantId: string,
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
|
||||||
|
sql: string;
|
||||||
|
},
|
||||||
|
): Promise<CustomMigrationRecord> {
|
||||||
|
// Create the migration record
|
||||||
|
const migration = await this.createMigrationRecord(tenantKnex, {
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute it immediately
|
||||||
|
return this.executeMigration(tenantKnex, migration.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the custom_migrations table exists in the tenant database
|
||||||
|
*/
|
||||||
|
private async ensureMigrationsTable(tenantKnex: Knex): Promise<void> {
|
||||||
|
const hasTable = await tenantKnex.schema.hasTable('custom_migrations');
|
||||||
|
|
||||||
|
if (!hasTable) {
|
||||||
|
await tenantKnex.schema.createTable('custom_migrations', (table) => {
|
||||||
|
table.uuid('id').primary();
|
||||||
|
table.uuid('tenantId').notNullable();
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.enum('type', ['create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom']).notNullable();
|
||||||
|
table.text('sql').notNullable();
|
||||||
|
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
|
||||||
|
table.timestamp('executedAt').nullable();
|
||||||
|
table.text('error').nullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.index(['tenantId']);
|
||||||
|
table.index(['status']);
|
||||||
|
table.index(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('Created custom_migrations table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all migrations for a tenant
|
||||||
|
*/
|
||||||
|
async getMigrations(
|
||||||
|
tenantKnex: Knex,
|
||||||
|
tenantId: string,
|
||||||
|
filter?: {
|
||||||
|
status?: 'pending' | 'executed' | 'failed';
|
||||||
|
type?: string;
|
||||||
|
},
|
||||||
|
): Promise<CustomMigrationRecord[]> {
|
||||||
|
await this.ensureMigrationsTable(tenantKnex);
|
||||||
|
|
||||||
|
let query = tenantKnex('custom_migrations').where({ tenantId });
|
||||||
|
|
||||||
|
if (filter?.status) {
|
||||||
|
query = query.where({ status: filter.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.type) {
|
||||||
|
query = query.where({ type: filter.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.orderBy('created_at', 'asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/migration/migration.module.ts
Normal file
10
backend/src/migration/migration.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CustomMigrationService } from './custom-migration.service';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
providers: [CustomMigrationService],
|
||||||
|
exports: [CustomMigrationService],
|
||||||
|
})
|
||||||
|
export class MigrationModule {}
|
||||||
14
backend/src/models/activity-log.model.ts
Normal file
14
backend/src/models/activity-log.model.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ActivityLog extends BaseModel {
|
||||||
|
static tableName = 'activity_logs';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
action!: string;
|
||||||
|
subjectType!: string;
|
||||||
|
subjectId!: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
causerId?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
30
backend/src/models/approval-assignment.model.ts
Normal file
30
backend/src/models/approval-assignment.model.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalStep } from './approval-step.model';
|
||||||
|
|
||||||
|
export class ApprovalAssignment extends BaseModel {
|
||||||
|
static tableName = 'approval_assignments';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
stepId!: string;
|
||||||
|
assigneeId!: string;
|
||||||
|
status!: string;
|
||||||
|
response?: string | null;
|
||||||
|
respondedAt?: Date | null;
|
||||||
|
dueAt?: Date | null;
|
||||||
|
reassignedFromId?: string | null;
|
||||||
|
delegatedById?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
step?: ApprovalStep;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
step: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-step.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_assignments.stepId',
|
||||||
|
to: 'approval_steps.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
31
backend/src/models/approval-definition.model.ts
Normal file
31
backend/src/models/approval-definition.model.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ApprovalDefinition extends BaseModel {
|
||||||
|
static tableName = 'approval_definitions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType!: string;
|
||||||
|
targetObjectType?: string;
|
||||||
|
entryCriteria?: Record<string, any> | null;
|
||||||
|
steps?: any[] | null;
|
||||||
|
votingPolicy?: Record<string, any> | null;
|
||||||
|
rejectionRule?: string | null;
|
||||||
|
materialChangePolicy?: string | null;
|
||||||
|
version!: number;
|
||||||
|
isActive!: boolean;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
requests: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_definitions.id',
|
||||||
|
to: 'approval_requests.definitionId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
backend/src/models/approval-effect-log.model.ts
Normal file
23
backend/src/models/approval-effect-log.model.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ApprovalEffectLog extends BaseModel {
|
||||||
|
static tableName = 'approval_effect_logs';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
requestId!: string;
|
||||||
|
effectKey!: string;
|
||||||
|
status!: string;
|
||||||
|
response?: Record<string, any> | null;
|
||||||
|
executedAt!: Date;
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
request: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_effect_logs.requestId',
|
||||||
|
to: 'approval_requests.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
backend/src/models/approval-request.model.ts
Normal file
55
backend/src/models/approval-request.model.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalDefinition } from './approval-definition.model';
|
||||||
|
import { ApprovalStep } from './approval-step.model';
|
||||||
|
import { ApprovalEffectLog } from './approval-effect-log.model';
|
||||||
|
|
||||||
|
export class ApprovalRequest extends BaseModel {
|
||||||
|
static tableName = 'approval_requests';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
definitionId!: string;
|
||||||
|
status!: string;
|
||||||
|
targetObjectType!: string;
|
||||||
|
targetObjectId!: string;
|
||||||
|
action?: string;
|
||||||
|
stateFrom?: string;
|
||||||
|
stateTo?: string;
|
||||||
|
fieldChanges?: Record<string, any> | null;
|
||||||
|
snapshot?: Record<string, any> | null;
|
||||||
|
versionHash?: string | null;
|
||||||
|
submittedById?: string | null;
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
currentStepKey?: string | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
definition?: ApprovalDefinition;
|
||||||
|
steps?: ApprovalStep[];
|
||||||
|
effectLogs?: ApprovalEffectLog[];
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
definition: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-definition.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.definitionId',
|
||||||
|
to: 'approval_definitions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-step.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.id',
|
||||||
|
to: 'approval_steps.requestId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
effectLogs: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-effect-log.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_requests.id',
|
||||||
|
to: 'approval_effect_logs.requestId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
41
backend/src/models/approval-step.model.ts
Normal file
41
backend/src/models/approval-step.model.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { ApprovalRequest } from './approval-request.model';
|
||||||
|
import { ApprovalAssignment } from './approval-assignment.model';
|
||||||
|
|
||||||
|
export class ApprovalStep extends BaseModel {
|
||||||
|
static tableName = 'approval_steps';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
requestId!: string;
|
||||||
|
stepKey!: string;
|
||||||
|
name!: string;
|
||||||
|
stepOrder!: number;
|
||||||
|
status!: string;
|
||||||
|
routing?: Record<string, any> | null;
|
||||||
|
voting?: Record<string, any> | null;
|
||||||
|
dueAt?: Date | null;
|
||||||
|
completedAt?: Date | null;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
request?: ApprovalRequest;
|
||||||
|
assignments?: ApprovalAssignment[];
|
||||||
|
|
||||||
|
static relationMappings = () => ({
|
||||||
|
request: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'approval-request.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_steps.requestId',
|
||||||
|
to: 'approval_requests.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: 'approval-assignment.model',
|
||||||
|
join: {
|
||||||
|
from: 'approval_steps.id',
|
||||||
|
to: 'approval_assignments.stepId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,38 @@
|
|||||||
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
|
import { Model, ModelOptions, QueryContext } 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;
|
||||||
|
|||||||
114
backend/src/models/central.model.ts
Normal file
114
backend/src/models/central.model.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Model, ModelOptions, QueryContext } from 'objection';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central database models using Objection.js
|
||||||
|
* These models work with the central database (not tenant databases)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CentralTenant extends Model {
|
||||||
|
static tableName = 'tenants';
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
dbHost: string;
|
||||||
|
dbPort: number;
|
||||||
|
dbName: string;
|
||||||
|
dbUsername: string;
|
||||||
|
dbPassword: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
domains?: CentralDomain[];
|
||||||
|
|
||||||
|
$beforeInsert(queryContext: QueryContext) {
|
||||||
|
this.id = this.id || randomUUID();
|
||||||
|
// Auto-generate slug from name if not provided
|
||||||
|
if (!this.slug && this.name) {
|
||||||
|
this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
this.createdAt = new Date();
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
return {
|
||||||
|
domains: {
|
||||||
|
relation: Model.HasManyRelation,
|
||||||
|
modelClass: CentralDomain,
|
||||||
|
join: {
|
||||||
|
from: 'tenants.id',
|
||||||
|
to: 'domains.tenantId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CentralDomain extends Model {
|
||||||
|
static tableName = 'domains';
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
tenantId: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
tenant?: CentralTenant;
|
||||||
|
|
||||||
|
$beforeInsert(queryContext: QueryContext) {
|
||||||
|
this.id = this.id || randomUUID();
|
||||||
|
this.createdAt = new Date();
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
return {
|
||||||
|
tenant: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: CentralTenant,
|
||||||
|
join: {
|
||||||
|
from: 'domains.tenantId',
|
||||||
|
to: 'tenants.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CentralUser extends Model {
|
||||||
|
static tableName = 'users';
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
$beforeInsert(queryContext: QueryContext) {
|
||||||
|
this.id = this.id || randomUUID();
|
||||||
|
this.createdAt = new Date();
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/models/contact-detail.model.ts
Normal file
33
backend/src/models/contact-detail.model.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ContactDetail extends BaseModel {
|
||||||
|
static tableName = 'contact_details';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
relatedObjectType!: 'Account' | 'Contact';
|
||||||
|
relatedObjectId!: string;
|
||||||
|
detailType!: string;
|
||||||
|
label?: string;
|
||||||
|
value!: string;
|
||||||
|
isPrimary!: boolean;
|
||||||
|
|
||||||
|
// Provide optional relations for each supported parent type.
|
||||||
|
static relationMappings = {
|
||||||
|
account: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'account.model',
|
||||||
|
join: {
|
||||||
|
from: 'contact_details.relatedObjectId',
|
||||||
|
to: 'accounts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'contact.model',
|
||||||
|
join: {
|
||||||
|
from: 'contact_details.relatedObjectId',
|
||||||
|
to: 'contacts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
30
backend/src/models/contact.model.ts
Normal file
30
backend/src/models/contact.model.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class Contact extends BaseModel {
|
||||||
|
static tableName = 'contacts';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
firstName!: string;
|
||||||
|
lastName!: string;
|
||||||
|
accountId!: string;
|
||||||
|
ownerId?: string;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
account: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'account.model',
|
||||||
|
join: {
|
||||||
|
from: 'contacts.accountId',
|
||||||
|
to: 'accounts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'user.model',
|
||||||
|
join: {
|
||||||
|
from: 'contacts.ownerId',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ 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.
|
||||||
@@ -74,5 +76,13 @@ 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,8 +10,11 @@ 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 {
|
||||||
@@ -25,12 +28,14 @@ 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: {
|
||||||
@@ -41,6 +46,14 @@ 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
backend/src/models/record-share.model.ts
Normal file
113
backend/src/models/record-share.model.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export interface RecordShareAccessLevel {
|
||||||
|
canRead: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordShare extends BaseModel {
|
||||||
|
static tableName = 'record_shares';
|
||||||
|
|
||||||
|
// Don't use snake_case mapping since DB columns are already camelCase
|
||||||
|
static get columnNameMappers() {
|
||||||
|
return {
|
||||||
|
parse(obj: any) {
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
format(obj: any) {
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't auto-set timestamps - let DB defaults handle them
|
||||||
|
$beforeInsert() {
|
||||||
|
// Don't call super - skip BaseModel's timestamp logic
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeUpdate() {
|
||||||
|
// Don't call super - skip BaseModel's timestamp logic
|
||||||
|
}
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
objectDefinitionId!: string;
|
||||||
|
recordId!: string;
|
||||||
|
granteeUserId!: string;
|
||||||
|
grantedByUserId!: string;
|
||||||
|
accessLevel!: RecordShareAccessLevel;
|
||||||
|
expiresAt?: Date;
|
||||||
|
revokedAt?: Date;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
static get jsonSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
required: ['objectDefinitionId', 'recordId', 'granteeUserId', 'grantedByUserId', 'accessLevel'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
objectDefinitionId: { type: 'string' },
|
||||||
|
recordId: { type: 'string' },
|
||||||
|
granteeUserId: { type: 'string' },
|
||||||
|
grantedByUserId: { type: 'string' },
|
||||||
|
accessLevel: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
canRead: { type: 'boolean' },
|
||||||
|
canEdit: { type: 'boolean' },
|
||||||
|
canDelete: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
{ type: 'null' },
|
||||||
|
{ type: 'object' } // Allow Date objects
|
||||||
|
]
|
||||||
|
},
|
||||||
|
revokedAt: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
{ type: 'null' },
|
||||||
|
{ type: 'object' } // Allow Date objects
|
||||||
|
]
|
||||||
|
},
|
||||||
|
createdAt: { type: ['string', 'object'], format: 'date-time' },
|
||||||
|
updatedAt: { type: ['string', 'object'], format: 'date-time' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { ObjectDefinition } = require('./object-definition.model');
|
||||||
|
const { User } = require('./user.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
objectDefinition: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: ObjectDefinition,
|
||||||
|
join: {
|
||||||
|
from: 'record_shares.objectDefinitionId',
|
||||||
|
to: 'object_definitions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
granteeUser: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: User,
|
||||||
|
join: {
|
||||||
|
from: 'record_shares.granteeUserId',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grantedByUser: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: User,
|
||||||
|
join: {
|
||||||
|
from: 'record_shares.grantedByUserId',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/models/role-field-permission.model.ts
Normal file
51
backend/src/models/role-field-permission.model.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class RoleFieldPermission extends BaseModel {
|
||||||
|
static tableName = 'role_field_permissions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
roleId!: string;
|
||||||
|
fieldDefinitionId!: string;
|
||||||
|
canRead!: boolean;
|
||||||
|
canEdit!: boolean;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
static get jsonSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
required: ['roleId', 'fieldDefinitionId'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
roleId: { type: 'string' },
|
||||||
|
fieldDefinitionId: { type: 'string' },
|
||||||
|
canRead: { type: 'boolean' },
|
||||||
|
canEdit: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { Role } = require('./role.model');
|
||||||
|
const { FieldDefinition } = require('./field-definition.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: Role,
|
||||||
|
join: {
|
||||||
|
from: 'role_field_permissions.roleId',
|
||||||
|
to: 'roles.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fieldDefinition: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: FieldDefinition,
|
||||||
|
join: {
|
||||||
|
from: 'role_field_permissions.fieldDefinitionId',
|
||||||
|
to: 'field_definitions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/src/models/role-object-permission.model.ts
Normal file
59
backend/src/models/role-object-permission.model.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class RoleObjectPermission extends BaseModel {
|
||||||
|
static tableName = 'role_object_permissions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
roleId!: string;
|
||||||
|
objectDefinitionId!: string;
|
||||||
|
canCreate!: boolean;
|
||||||
|
canRead!: boolean;
|
||||||
|
canEdit!: boolean;
|
||||||
|
canDelete!: boolean;
|
||||||
|
canViewAll!: boolean;
|
||||||
|
canModifyAll!: boolean;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
static get jsonSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
required: ['roleId', 'objectDefinitionId'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
roleId: { type: 'string' },
|
||||||
|
objectDefinitionId: { type: 'string' },
|
||||||
|
canCreate: { type: 'boolean' },
|
||||||
|
canRead: { type: 'boolean' },
|
||||||
|
canEdit: { type: 'boolean' },
|
||||||
|
canDelete: { type: 'boolean' },
|
||||||
|
canViewAll: { type: 'boolean' },
|
||||||
|
canModifyAll: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { Role } = require('./role.model');
|
||||||
|
const { ObjectDefinition } = require('./object-definition.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: Role,
|
||||||
|
join: {
|
||||||
|
from: 'role_object_permissions.roleId',
|
||||||
|
to: 'roles.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
objectDefinition: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: ObjectDefinition,
|
||||||
|
join: {
|
||||||
|
from: 'role_object_permissions.objectDefinitionId',
|
||||||
|
to: 'object_definitions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ 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: {
|
||||||
@@ -61,6 +63,22 @@ 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
backend/src/models/task.model.ts
Normal file
15
backend/src/models/task.model.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class Task extends BaseModel {
|
||||||
|
static tableName = 'tasks';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
title!: string;
|
||||||
|
description?: string;
|
||||||
|
status!: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: Date;
|
||||||
|
assignedToId?: string | null;
|
||||||
|
relatedObjectType?: string | null;
|
||||||
|
relatedObjectId?: string | null;
|
||||||
|
}
|
||||||
@@ -22,7 +22,9 @@ 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;
|
||||||
@@ -43,6 +45,14 @@ 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()
|
||||||
@@ -51,13 +61,29 @@ 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 {
|
||||||
const uiMetadata = field.uiMetadata || {};
|
// Parse ui_metadata if it's a JSON string or object
|
||||||
|
let uiMetadata: any = {};
|
||||||
|
const metadataField = field.ui_metadata || field.uiMetadata;
|
||||||
|
if (metadataField) {
|
||||||
|
if (typeof metadataField === 'string') {
|
||||||
|
try {
|
||||||
|
uiMetadata = JSON.parse(metadataField);
|
||||||
|
} catch (e) {
|
||||||
|
uiMetadata = {};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uiMetadata = metadataField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontendType = this.mapFieldType(field.type);
|
||||||
|
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: field.id,
|
id: field.id,
|
||||||
apiName: field.apiName,
|
apiName: field.apiName,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
type: this.mapFieldType(field.type),
|
type: frontendType,
|
||||||
|
|
||||||
// Display properties
|
// Display properties
|
||||||
placeholder: uiMetadata.placeholder || field.description,
|
placeholder: uiMetadata.placeholder || field.description,
|
||||||
@@ -82,7 +108,12 @@ export class FieldMapperService {
|
|||||||
step: uiMetadata.step,
|
step: uiMetadata.step,
|
||||||
accept: uiMetadata.accept,
|
accept: uiMetadata.accept,
|
||||||
relationObject: field.referenceObject,
|
relationObject: field.referenceObject,
|
||||||
relationDisplayField: uiMetadata.relationDisplayField,
|
relationObjects: uiMetadata.relationObjects,
|
||||||
|
// 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,
|
||||||
@@ -187,6 +218,17 @@ 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,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
backend/src/object/models/base.model.ts
Normal file
33
backend/src/object/models/base.model.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
258
backend/src/object/models/dynamic-model.factory.ts
Normal file
258
backend/src/object/models/dynamic-model.factory.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/object/models/model.registry.ts
Normal file
73
backend/src/object/models/model.registry.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
203
backend/src/object/models/model.service.ts
Normal file
203
backend/src/object/models/model.service.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
backend/src/object/models/system-models.ts
Normal file
85
backend/src/object/models/system-models.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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,11 +5,22 @@ 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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule],
|
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule],
|
||||||
providers: [ObjectService, SchemaManagementService, FieldMapperService],
|
providers: [
|
||||||
|
ObjectService,
|
||||||
|
SchemaManagementService,
|
||||||
|
FieldMapperService,
|
||||||
|
ModelRegistry,
|
||||||
|
ModelService,
|
||||||
|
],
|
||||||
controllers: [RuntimeObjectController, SetupObjectController],
|
controllers: [RuntimeObjectController, SetupObjectController],
|
||||||
exports: [ObjectService, SchemaManagementService, FieldMapperService],
|
exports: [ObjectService, SchemaManagementService, FieldMapperService, ModelService],
|
||||||
})
|
})
|
||||||
export class ObjectModule {}
|
export class ObjectModule {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,4 +95,20 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export class SchemaManagementService {
|
|||||||
objectDefinition: ObjectDefinition,
|
objectDefinition: ObjectDefinition,
|
||||||
fields: FieldDefinition[],
|
fields: FieldDefinition[],
|
||||||
) {
|
) {
|
||||||
const tableName = this.getTableName(objectDefinition.apiName);
|
const tableName = this.getTableName(
|
||||||
|
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);
|
||||||
@@ -44,8 +48,10 @@ export class SchemaManagementService {
|
|||||||
knex: Knex,
|
knex: Knex,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
field: FieldDefinition,
|
field: FieldDefinition,
|
||||||
|
objectLabel?: string,
|
||||||
|
pluralLabel?: string,
|
||||||
) {
|
) {
|
||||||
const tableName = this.getTableName(objectApiName);
|
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
|
||||||
|
|
||||||
await knex.schema.alterTable(tableName, (table) => {
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
this.addFieldColumn(table, field);
|
this.addFieldColumn(table, field);
|
||||||
@@ -61,8 +67,10 @@ export class SchemaManagementService {
|
|||||||
knex: Knex,
|
knex: Knex,
|
||||||
objectApiName: string,
|
objectApiName: string,
|
||||||
fieldApiName: string,
|
fieldApiName: string,
|
||||||
|
objectLabel?: string,
|
||||||
|
pluralLabel?: string,
|
||||||
) {
|
) {
|
||||||
const tableName = this.getTableName(objectApiName);
|
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
|
||||||
|
|
||||||
await knex.schema.alterTable(tableName, (table) => {
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
table.dropColumn(fieldApiName);
|
table.dropColumn(fieldApiName);
|
||||||
@@ -71,11 +79,44 @@ 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) {
|
async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
|
||||||
const tableName = this.getTableName(objectApiName);
|
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
|
||||||
|
|
||||||
await knex.schema.dropTableIfExists(tableName);
|
await knex.schema.dropTableIfExists(tableName);
|
||||||
|
|
||||||
@@ -94,15 +135,30 @@ 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,
|
||||||
@@ -115,18 +171,28 @@ 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);
|
||||||
@@ -134,19 +200,30 @@ 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;
|
||||||
|
|
||||||
@@ -174,16 +251,35 @@ 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): string {
|
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
|
||||||
// Convert PascalCase to snake_case
|
const toSnakePlural = (source: string): string => {
|
||||||
const snakeCase = apiName
|
const cleaned = source.replace(/[\s-]+/g, '_');
|
||||||
.replace(/([A-Z])/g, '_$1')
|
const snake = cleaned
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||||
|
.replace(/__+/g, '_')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/^_/, '');
|
.replace(/^_/, '');
|
||||||
|
|
||||||
// Simple pluralization (append 's' if not already plural)
|
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
|
||||||
// In production, use a proper pluralization library
|
if (snake.endsWith('s')) return snake;
|
||||||
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Patch,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -10,6 +13,7 @@ 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)
|
||||||
@@ -17,6 +21,7 @@ export class SetupObjectController {
|
|||||||
constructor(
|
constructor(
|
||||||
private objectService: ObjectService,
|
private objectService: ObjectService,
|
||||||
private fieldMapperService: FieldMapperService,
|
private fieldMapperService: FieldMapperService,
|
||||||
|
private tenantDbService: TenantDatabaseService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -29,7 +34,8 @@ export class SetupObjectController {
|
|||||||
@TenantId() tenantId: string,
|
@TenantId() tenantId: string,
|
||||||
@Param('objectApiName') objectApiName: string,
|
@Param('objectApiName') objectApiName: string,
|
||||||
) {
|
) {
|
||||||
return this.objectService.getObjectDefinition(tenantId, objectApiName);
|
const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
|
||||||
|
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':objectApiName/ui-config')
|
@Get(':objectApiName/ui-config')
|
||||||
@@ -58,10 +64,93 @@ export class SetupObjectController {
|
|||||||
@Param('objectApiName') objectApiName: string,
|
@Param('objectApiName') objectApiName: string,
|
||||||
@Body() data: any,
|
@Body() data: any,
|
||||||
) {
|
) {
|
||||||
return this.objectService.createFieldDefinition(
|
const field = await 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/src/page-layout/dto/page-layout.dto.ts
Normal file
56
backend/src/page-layout/dto/page-layout.dto.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePageLayoutDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
objectId: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
layoutConfig: {
|
||||||
|
fields: Array<{
|
||||||
|
fieldId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}>;
|
||||||
|
relatedLists?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePageLayoutDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
layoutConfig?: {
|
||||||
|
fields: Array<{
|
||||||
|
fieldId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}>;
|
||||||
|
relatedLists?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
55
backend/src/page-layout/page-layout.controller.ts
Normal file
55
backend/src/page-layout/page-layout.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
UseGuards,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PageLayoutService } from './page-layout.service';
|
||||||
|
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('page-layouts')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PageLayoutController {
|
||||||
|
constructor(private readonly pageLayoutService: PageLayoutService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@TenantId() tenantId: string, @Body() createPageLayoutDto: CreatePageLayoutDto) {
|
||||||
|
return this.pageLayoutService.create(tenantId, createPageLayoutDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
|
||||||
|
return this.pageLayoutService.findAll(tenantId, objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('default/:objectId')
|
||||||
|
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
|
||||||
|
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@TenantId() tenantId: string, @Param('id') id: string) {
|
||||||
|
return this.pageLayoutService.findOne(tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updatePageLayoutDto: UpdatePageLayoutDto,
|
||||||
|
) {
|
||||||
|
return this.pageLayoutService.update(tenantId, id, updatePageLayoutDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@TenantId() tenantId: string, @Param('id') id: string) {
|
||||||
|
return this.pageLayoutService.remove(tenantId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/page-layout/page-layout.module.ts
Normal file
12
backend/src/page-layout/page-layout.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PageLayoutService } from './page-layout.service';
|
||||||
|
import { PageLayoutController } from './page-layout.controller';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
controllers: [PageLayoutController],
|
||||||
|
providers: [PageLayoutService],
|
||||||
|
exports: [PageLayoutService],
|
||||||
|
})
|
||||||
|
export class PageLayoutModule {}
|
||||||
118
backend/src/page-layout/page-layout.service.ts
Normal file
118
backend/src/page-layout/page-layout.service.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageLayoutService {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
async create(tenantId: string, createDto: CreatePageLayoutDto) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
// If this layout is set as default, unset other defaults for the same object
|
||||||
|
if (createDto.isDefault) {
|
||||||
|
await knex('page_layouts')
|
||||||
|
.where({ object_id: createDto.objectId })
|
||||||
|
.update({ is_default: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [id] = await knex('page_layouts').insert({
|
||||||
|
name: createDto.name,
|
||||||
|
object_id: createDto.objectId,
|
||||||
|
is_default: createDto.isDefault || false,
|
||||||
|
layout_config: JSON.stringify(createDto.layoutConfig),
|
||||||
|
description: createDto.description || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the inserted record
|
||||||
|
const result = await knex('page_layouts').where({ id }).first();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, objectId?: string) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
let query = knex('page_layouts');
|
||||||
|
|
||||||
|
if (objectId) {
|
||||||
|
query = query.where({ object_id: objectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const layouts = await query.orderByRaw('is_default DESC, name ASC');
|
||||||
|
return layouts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(tenantId: string, id: string) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
const layout = await knex('page_layouts').where({ id }).first();
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
throw new NotFoundException(`Page layout with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDefaultByObject(tenantId: string, objectId: string) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
const layout = await knex('page_layouts')
|
||||||
|
.where({ object_id: objectId, is_default: true })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return layout || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, id: string, updateDto: UpdatePageLayoutDto) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
// Check if layout exists
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
// If setting as default, unset other defaults for the same object
|
||||||
|
if (updateDto.isDefault) {
|
||||||
|
const layout = await this.findOne(tenantId, id);
|
||||||
|
await knex('page_layouts')
|
||||||
|
.where({ object_id: layout.object_id })
|
||||||
|
.whereNot({ id })
|
||||||
|
.update({ is_default: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: any = {};
|
||||||
|
|
||||||
|
if (updateDto.name !== undefined) {
|
||||||
|
updates.name = updateDto.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateDto.isDefault !== undefined) {
|
||||||
|
updates.is_default = updateDto.isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateDto.layoutConfig !== undefined) {
|
||||||
|
updates.layout_config = JSON.stringify(updateDto.layoutConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateDto.description !== undefined) {
|
||||||
|
updates.description = updateDto.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.updated_at = knex.fn.now();
|
||||||
|
|
||||||
|
await knex('page_layouts').where({ id }).update(updates);
|
||||||
|
|
||||||
|
// Get the updated record
|
||||||
|
const result = await knex('page_layouts').where({ id }).first();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, id: string) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
await knex('page_layouts').where({ id }).delete();
|
||||||
|
|
||||||
|
return { message: 'Page layout deleted successfully' };
|
||||||
|
}
|
||||||
|
}
|
||||||
199
backend/src/rbac/ability.factory.ts
Normal file
199
backend/src/rbac/ability.factory.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
282
backend/src/rbac/authorization.service.ts
Normal file
282
backend/src/rbac/authorization.service.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/rbac/dto/create-record-share.dto.ts
Normal file
19
backend/src/rbac/dto/create-record-share.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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,8 +1,16 @@
|
|||||||
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({
|
||||||
providers: [RbacService],
|
imports: [TenantModule],
|
||||||
exports: [RbacService],
|
controllers: [SetupRolesController, SetupUsersController, RecordSharingController],
|
||||||
|
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
|
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
})
|
})
|
||||||
export class RbacModule {}
|
export class RbacModule {}
|
||||||
|
|||||||
350
backend/src/rbac/record-sharing.controller.ts
Normal file
350
backend/src/rbac/record-sharing.controller.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/src/rbac/setup-roles.controller.ts
Normal file
141
backend/src/rbac/setup-roles.controller.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
146
backend/src/rbac/setup-users.controller.ts
Normal file
146
backend/src/rbac/setup-users.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { User } from '../models/user.model';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
@Controller('setup/users')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class SetupUsersController {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getUsers(@TenantId() tenantId: string) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
return await User.query(knex).withGraphFetched('roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
return await User.query(knex).findById(id).withGraphFetched('roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() data: { email: string; password: string; firstName?: string; lastName?: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
|
const user = await User.query(knex).insert({
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async updateUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: { email?: string; password?: string; firstName?: string; lastName?: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (data.email) updateData.email = data.email;
|
||||||
|
if (data.firstName !== undefined) updateData.firstName = data.firstName;
|
||||||
|
if (data.lastName !== undefined) updateData.lastName = data.lastName;
|
||||||
|
|
||||||
|
// Hash password if provided
|
||||||
|
if (data.password) {
|
||||||
|
updateData.password = await bcrypt.hash(data.password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.query(knex).patchAndFetchById(id, updateData);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Delete user role assignments first
|
||||||
|
await knex('user_roles').where({ userId: id }).delete();
|
||||||
|
|
||||||
|
// Delete the user
|
||||||
|
await User.query(knex).deleteById(id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':userId/roles')
|
||||||
|
async addRoleToUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() data: { roleId: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Check if assignment already exists
|
||||||
|
const existing = await knex('user_roles')
|
||||||
|
.where({ userId, roleId: data.roleId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: true, message: 'Role already assigned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex('user_roles').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
userId,
|
||||||
|
roleId: data.roleId,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':userId/roles/:roleId')
|
||||||
|
async removeRoleFromUser(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('roleId') roleId: string,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
await knex('user_roles')
|
||||||
|
.where({ userId, roleId })
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/search/meilisearch.module.ts
Normal file
8
backend/src/search/meilisearch.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MeilisearchService } from './meilisearch.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [MeilisearchService],
|
||||||
|
exports: [MeilisearchService],
|
||||||
|
})
|
||||||
|
export class MeilisearchModule {}
|
||||||
244
backend/src/search/meilisearch.service.ts
Normal file
244
backend/src/search/meilisearch.service.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
|
|
||||||
|
type MeiliConfig = {
|
||||||
|
host: string;
|
||||||
|
apiKey?: string;
|
||||||
|
indexPrefix: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MeilisearchService {
|
||||||
|
private readonly logger = new Logger(MeilisearchService.name);
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return Boolean(this.getConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
query: string,
|
||||||
|
displayField?: string,
|
||||||
|
): Promise<{ id: string; hit: any } | null> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||||
|
|
||||||
|
console.log('querying Meilisearch index:', { indexName, query, displayField });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestJson('POST', url, {
|
||||||
|
q: query,
|
||||||
|
limit: 5,
|
||||||
|
}, this.buildHeaders(config));
|
||||||
|
|
||||||
|
if (!this.isSuccessStatus(response.status)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Meilisearch query failed for index ${indexName}: ${response.status}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
|
||||||
|
if (hits.length === 0) return null;
|
||||||
|
|
||||||
|
if (displayField) {
|
||||||
|
const loweredQuery = query.toLowerCase();
|
||||||
|
const exactMatch = hits.find((hit: any) => {
|
||||||
|
const value = hit?.[displayField];
|
||||||
|
return value && String(value).toLowerCase() === loweredQuery;
|
||||||
|
});
|
||||||
|
if (exactMatch?.id) {
|
||||||
|
return { id: exactMatch.id, hit: exactMatch };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = hits[0];
|
||||||
|
if (match?.id) {
|
||||||
|
return { id: match.id, hit: match };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Meilisearch lookup failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchRecords(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
query: string,
|
||||||
|
options?: { limit?: number; offset?: number },
|
||||||
|
): Promise<{ hits: any[]; total: number }> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
if (!config) return { hits: [], total: 0 };
|
||||||
|
|
||||||
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||||
|
const limit = Number.isFinite(Number(options?.limit)) ? Number(options?.limit) : 20;
|
||||||
|
const offset = Number.isFinite(Number(options?.offset)) ? Number(options?.offset) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestJson('POST', url, {
|
||||||
|
q: query,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}, this.buildHeaders(config));
|
||||||
|
|
||||||
|
console.log('Meilisearch response body:', response.body);
|
||||||
|
|
||||||
|
if (!this.isSuccessStatus(response.status)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Meilisearch query failed for index ${indexName}: ${response.status}`,
|
||||||
|
);
|
||||||
|
return { hits: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
|
||||||
|
const total =
|
||||||
|
response.body?.estimatedTotalHits ??
|
||||||
|
response.body?.nbHits ??
|
||||||
|
hits.length;
|
||||||
|
return { hits, total };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Meilisearch query failed: ${error.message}`);
|
||||||
|
return { hits: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
record: Record<string, any>,
|
||||||
|
fieldsToIndex: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
if (!config || !record?.id) return;
|
||||||
|
|
||||||
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents?primaryKey=id`;
|
||||||
|
const document = this.pickRecordFields(record, fieldsToIndex);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestJson('POST', url, [document], this.buildHeaders(config));
|
||||||
|
if (!this.isSuccessStatus(response.status)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Meilisearch upsert failed for index ${indexName}: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Meilisearch upsert failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecord(
|
||||||
|
tenantId: string,
|
||||||
|
objectApiName: string,
|
||||||
|
recordId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
if (!config || !recordId) return;
|
||||||
|
|
||||||
|
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||||
|
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents/${encodeURIComponent(recordId)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.requestJson('DELETE', url, undefined, this.buildHeaders(config));
|
||||||
|
if (!this.isSuccessStatus(response.status)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Meilisearch delete failed for index ${indexName}: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Meilisearch delete failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(): MeiliConfig | null {
|
||||||
|
const host = process.env.MEILI_HOST || process.env.MEILISEARCH_HOST;
|
||||||
|
if (!host) return null;
|
||||||
|
const trimmedHost = host.replace(/\/+$/, '');
|
||||||
|
const apiKey = process.env.MEILI_API_KEY || process.env.MEILISEARCH_API_KEY;
|
||||||
|
const indexPrefix = process.env.MEILI_INDEX_PREFIX || 'tenant_';
|
||||||
|
return { host: trimmedHost, apiKey, indexPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildIndexName(config: MeiliConfig, tenantId: string, objectApiName: string): string {
|
||||||
|
return `${config.indexPrefix}${tenantId}_${objectApiName}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeaders(config: MeiliConfig): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
};
|
||||||
|
if (config.apiKey) {
|
||||||
|
headers['X-Meili-API-Key'] = config.apiKey;
|
||||||
|
headers.Authorization = `Bearer ${config.apiKey}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRecordFields(record: Record<string, any>, fields: string[]): Record<string, any> {
|
||||||
|
const document: Record<string, any> = { id: record.id };
|
||||||
|
for (const field of fields) {
|
||||||
|
if (record[field] !== undefined) {
|
||||||
|
document[field] = record[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSuccessStatus(status: number): boolean {
|
||||||
|
return status >= 200 && status < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestJson(
|
||||||
|
method: 'POST' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
payload: any,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<{ status: number; body: any }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const client = parsedUrl.protocol === 'https:' ? https : http;
|
||||||
|
const request = client.request(
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
hostname: parsedUrl.hostname,
|
||||||
|
port: parsedUrl.port,
|
||||||
|
path: `${parsedUrl.pathname}${parsedUrl.search}`,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let data = '';
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
if (!data) {
|
||||||
|
resolve({ status: response.statusCode || 0, body: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(data);
|
||||||
|
resolve({ status: response.statusCode || 0, body });
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
request.on('error', reject);
|
||||||
|
if (payload !== undefined) {
|
||||||
|
request.write(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/task/task.controller.ts
Normal file
63
backend/src/task/task.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { TaskService } from './task.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('tasks')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TaskController {
|
||||||
|
constructor(private taskService: TaskService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listTasks(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('assignedToId') assignedToId?: string,
|
||||||
|
@Query('relatedObjectType') relatedObjectType?: string,
|
||||||
|
@Query('relatedObjectId') relatedObjectId?: string,
|
||||||
|
) {
|
||||||
|
return this.taskService.listTasks(tenantId, {
|
||||||
|
status,
|
||||||
|
assignedToId,
|
||||||
|
relatedObjectType,
|
||||||
|
relatedObjectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createTask(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
relatedObjectType?: string;
|
||||||
|
relatedObjectId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.taskService.createTask(tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':taskId')
|
||||||
|
async updateTask(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('taskId') taskId: string,
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
relatedObjectType?: string;
|
||||||
|
relatedObjectId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.taskService.updateTask(tenantId, taskId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/task/task.module.ts
Normal file
12
backend/src/task/task.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TaskController } from './task.controller';
|
||||||
|
import { TaskService } from './task.service';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
|
controllers: [TaskController],
|
||||||
|
providers: [TaskService],
|
||||||
|
exports: [TaskService],
|
||||||
|
})
|
||||||
|
export class TaskModule {}
|
||||||
91
backend/src/task/task.service.ts
Normal file
91
backend/src/task/task.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { Task } from '../models/task.model';
|
||||||
|
|
||||||
|
export interface TaskInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueAt?: string | Date | null;
|
||||||
|
assignedToId?: string | null;
|
||||||
|
relatedObjectType?: string | null;
|
||||||
|
relatedObjectId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskService {
|
||||||
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
private async getKnex(tenantId: string) {
|
||||||
|
const resolved = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
return this.tenantDbService.getTenantKnexById(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTasks(
|
||||||
|
tenantId: string,
|
||||||
|
filters: {
|
||||||
|
status?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
relatedObjectType?: string;
|
||||||
|
relatedObjectId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex)
|
||||||
|
.modify((qb) => {
|
||||||
|
if (filters.status) qb.where('status', filters.status);
|
||||||
|
if (filters.assignedToId) qb.where('assignedToId', filters.assignedToId);
|
||||||
|
if (filters.relatedObjectType)
|
||||||
|
qb.where('relatedObjectType', filters.relatedObjectType);
|
||||||
|
if (filters.relatedObjectId) qb.where('relatedObjectId', filters.relatedObjectId);
|
||||||
|
})
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(tenantId: string, input: TaskInput) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex).insert({
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
status: input.status ?? 'open',
|
||||||
|
priority: input.priority,
|
||||||
|
dueAt: input.dueAt ? new Date(input.dueAt) : null,
|
||||||
|
assignedToId: input.assignedToId ?? null,
|
||||||
|
relatedObjectType: input.relatedObjectType ?? null,
|
||||||
|
relatedObjectId: input.relatedObjectId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(tenantId: string, taskId: string, input: Partial<TaskInput>) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex).patchAndFetchById(taskId, {
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
status: input.status,
|
||||||
|
priority: input.priority,
|
||||||
|
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
||||||
|
assignedToId: input.assignedToId ?? undefined,
|
||||||
|
relatedObjectType: input.relatedObjectType ?? undefined,
|
||||||
|
relatedObjectId: input.relatedObjectId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTaskByRelated(
|
||||||
|
tenantId: string,
|
||||||
|
relatedObjectType: string,
|
||||||
|
relatedObjectId: string,
|
||||||
|
input: Partial<TaskInput>,
|
||||||
|
) {
|
||||||
|
const knex = await this.getKnex(tenantId);
|
||||||
|
return Task.query(knex)
|
||||||
|
.patch({
|
||||||
|
status: input.status,
|
||||||
|
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
||||||
|
})
|
||||||
|
.where({
|
||||||
|
relatedObjectType,
|
||||||
|
relatedObjectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
368
backend/src/tenant/central-admin.controller.ts
Normal file
368
backend/src/tenant/central-admin.controller.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UnauthorizedException,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
|
||||||
|
import { getCentralKnex, initCentralModels } from './central-database.service';
|
||||||
|
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||||
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for managing central database entities (tenants, domains, users)
|
||||||
|
* Only accessible when logged in as central admin
|
||||||
|
*/
|
||||||
|
@Controller('central')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class CentralAdminController {
|
||||||
|
constructor(
|
||||||
|
private readonly provisioningService: TenantProvisioningService,
|
||||||
|
private readonly tenantDbService: TenantDatabaseService,
|
||||||
|
) {
|
||||||
|
// Initialize central models on controller creation
|
||||||
|
initCentralModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkCentralAdmin(req: any) {
|
||||||
|
const subdomain = req.raw?.subdomain;
|
||||||
|
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
|
||||||
|
|
||||||
|
if (!subdomain || !centralSubdomains.includes(subdomain)) {
|
||||||
|
throw new UnauthorizedException('This endpoint is only accessible to central administrators');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== TENANTS ====================
|
||||||
|
|
||||||
|
@Get('tenants')
|
||||||
|
async getTenants(@Req() req: any) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
return CentralTenant.query().withGraphFetched('domains');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tenants/:id')
|
||||||
|
async getTenant(@Req() req: any, @Param('id') id: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
return CentralTenant.query()
|
||||||
|
.findById(id)
|
||||||
|
.withGraphFetched('domains');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tenants')
|
||||||
|
async createTenant(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() data: {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
primaryDomain: string;
|
||||||
|
dbHost?: string;
|
||||||
|
dbPort?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
// Use the provisioning service to create tenant with database and migrations
|
||||||
|
const result = await this.provisioningService.provisionTenant({
|
||||||
|
name: data.name,
|
||||||
|
slug: data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
|
||||||
|
primaryDomain: data.primaryDomain,
|
||||||
|
dbHost: data.dbHost,
|
||||||
|
dbPort: data.dbPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the created tenant
|
||||||
|
return CentralTenant.query()
|
||||||
|
.findById(result.tenantId)
|
||||||
|
.withGraphFetched('domains');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('tenants/:id')
|
||||||
|
async updateTenant(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
dbHost?: string;
|
||||||
|
dbPort?: number;
|
||||||
|
dbName?: string;
|
||||||
|
dbUsername?: string;
|
||||||
|
status?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
return CentralTenant.query()
|
||||||
|
.patchAndFetchById(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('tenants/:id')
|
||||||
|
async deleteTenant(@Req() req: any, @Param('id') id: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
await CentralTenant.query().deleteById(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users for a specific tenant
|
||||||
|
@Get('tenants/:id/users')
|
||||||
|
async getTenantUsers(@Req() req: any, @Param('id') tenantId: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get tenant to verify it exists
|
||||||
|
const tenant = await CentralTenant.query().findById(tenantId);
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new UnauthorizedException('Tenant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to tenant database using tenant ID directly
|
||||||
|
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||||
|
|
||||||
|
// Fetch users from tenant database
|
||||||
|
const users = await tenantKnex('users').select('*');
|
||||||
|
|
||||||
|
// Remove password from response
|
||||||
|
return users.map(({ password, ...user }) => user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tenant users:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a user in a specific tenant
|
||||||
|
@Post('tenants/:id/users')
|
||||||
|
async createTenantUser(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') tenantId: string,
|
||||||
|
@Body() data: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get tenant to verify it exists
|
||||||
|
const tenant = await CentralTenant.query().findById(tenantId);
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new UnauthorizedException('Tenant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to tenant database using tenant ID directly
|
||||||
|
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
|
// Generate UUID for the new user
|
||||||
|
const userId = require('crypto').randomUUID();
|
||||||
|
|
||||||
|
// Create user in tenant database
|
||||||
|
await tenantKnex('users').insert({
|
||||||
|
id: userId,
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: data.firstName || null,
|
||||||
|
lastName: data.lastName || null,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch and return the created user
|
||||||
|
const user = await tenantKnex('users').where('id', userId).first();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Failed to create user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
return userWithoutPassword;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating tenant user:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DOMAINS ====================
|
||||||
|
|
||||||
|
@Get('domains')
|
||||||
|
async getDomains(
|
||||||
|
@Req() req: any,
|
||||||
|
@Query('parentId') parentId?: string,
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
let query = CentralDomain.query().withGraphFetched('tenant');
|
||||||
|
|
||||||
|
// Filter by parent/tenant ID if provided (for related lists)
|
||||||
|
if (parentId || tenantId) {
|
||||||
|
query = query.where('tenantId', parentId || tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('domains/:id')
|
||||||
|
async getDomain(@Req() req: any, @Param('id') id: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
return CentralDomain.query()
|
||||||
|
.findById(id)
|
||||||
|
.withGraphFetched('tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('domains')
|
||||||
|
async createDomain(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() data: {
|
||||||
|
domain: string;
|
||||||
|
tenantId: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
return CentralDomain.query().insert({
|
||||||
|
domain: data.domain,
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
isPrimary: data.isPrimary || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('domains/:id')
|
||||||
|
async updateDomain(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: {
|
||||||
|
domain?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
return CentralDomain.query()
|
||||||
|
.patchAndFetchById(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('domains/:id')
|
||||||
|
async deleteDomain(@Req() req: any, @Param('id') id: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
// Get domain info before deleting to invalidate cache
|
||||||
|
const domain = await CentralDomain.query().findById(id);
|
||||||
|
|
||||||
|
// Delete the domain
|
||||||
|
await CentralDomain.query().deleteById(id);
|
||||||
|
|
||||||
|
// Invalidate tenant connection cache for this domain
|
||||||
|
if (domain) {
|
||||||
|
this.tenantDbService.removeTenantConnection(domain.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USERS (Central Admin Users) ====================
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
async getUsers(@Req() req: any) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
const users = await CentralUser.query();
|
||||||
|
// Remove password from response
|
||||||
|
return users.map(({ password, ...user }) => user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users/:id')
|
||||||
|
async getUser(@Req() req: any, @Param('id') id: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
const user = await CentralUser.query().findById(id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users')
|
||||||
|
async createUser(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() data: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
role?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
|
const user = await CentralUser.query().insert({
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: data.firstName || null,
|
||||||
|
lastName: data.lastName || null,
|
||||||
|
role: data.role || 'admin',
|
||||||
|
isActive: data.isActive !== undefined ? data.isActive : true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('users/:id')
|
||||||
|
async updateUser(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
role?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
|
||||||
|
const updateData: any = { ...data };
|
||||||
|
|
||||||
|
// Hash password if provided
|
||||||
|
if (data.password) {
|
||||||
|
updateData.password = await bcrypt.hash(data.password, 10);
|
||||||
|
} else {
|
||||||
|
// Remove password from update if not provided
|
||||||
|
delete updateData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await CentralUser.query()
|
||||||
|
.patchAndFetchById(id, updateData);
|
||||||
|
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('users/:id')
|
||||||
|
async deleteUser(@Req() req: any, @Param('id') id: string) {
|
||||||
|
this.checkCentralAdmin(req);
|
||||||
|
await CentralUser.query().deleteById(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/tenant/central-database.service.ts
Normal file
43
backend/src/tenant/central-database.service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Knex from 'knex';
|
||||||
|
import { Model } from 'objection';
|
||||||
|
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
|
||||||
|
|
||||||
|
let centralKnex: Knex.Knex | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a Knex instance for the central database
|
||||||
|
* This is used for Objection models that work with central entities
|
||||||
|
*/
|
||||||
|
export function getCentralKnex(): Knex.Knex {
|
||||||
|
if (!centralKnex) {
|
||||||
|
const centralDbUrl = process.env.CENTRAL_DATABASE_URL;
|
||||||
|
|
||||||
|
if (!centralDbUrl) {
|
||||||
|
throw new Error('CENTRAL_DATABASE_URL environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
centralKnex = Knex({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: centralDbUrl,
|
||||||
|
pool: {
|
||||||
|
min: 2,
|
||||||
|
max: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind Objection models to this knex instance
|
||||||
|
Model.knex(centralKnex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return centralKnex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize central models with the knex instance
|
||||||
|
*/
|
||||||
|
export function initCentralModels() {
|
||||||
|
const knex = getCentralKnex();
|
||||||
|
CentralTenant.knex(knex);
|
||||||
|
CentralDomain.knex(knex);
|
||||||
|
CentralUser.knex(knex);
|
||||||
|
}
|
||||||
@@ -8,32 +8,116 @@ export class TenantDatabaseService {
|
|||||||
private readonly logger = new Logger(TenantDatabaseService.name);
|
private readonly logger = new Logger(TenantDatabaseService.name);
|
||||||
private tenantConnections: Map<string, Knex> = new Map();
|
private tenantConnections: Map<string, Knex> = new Map();
|
||||||
|
|
||||||
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
|
/**
|
||||||
if (this.tenantConnections.has(tenantIdOrSlug)) {
|
* Get tenant database connection by domain (for subdomain-based authentication)
|
||||||
return this.tenantConnections.get(tenantIdOrSlug);
|
* This is used when users log in via tenant subdomains
|
||||||
|
*/
|
||||||
|
async getTenantKnexByDomain(domain: string): Promise<Knex> {
|
||||||
|
const cacheKey = `domain:${domain}`;
|
||||||
|
|
||||||
|
// Check if we have a cached connection
|
||||||
|
if (this.tenantConnections.has(cacheKey)) {
|
||||||
|
// Validate the domain still exists before returning cached connection
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const domainRecord = await centralPrisma.domain.findUnique({
|
||||||
|
where: { domain },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If domain no longer exists, remove cached connection
|
||||||
|
if (!domainRecord) {
|
||||||
|
this.logger.warn(`Domain ${domain} no longer exists, removing cached connection`);
|
||||||
|
await this.disconnectTenant(cacheKey);
|
||||||
|
throw new Error(`Domain ${domain} not found`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If domain doesn't exist, remove from cache and re-throw
|
||||||
|
if (error.message.includes('not found')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// For other errors, log but continue with cached connection
|
||||||
|
this.logger.warn(`Error validating domain ${domain}:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tenantConnections.get(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const centralPrisma = getCentralPrisma();
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
// Try to find tenant by ID first, then by slug
|
// Find tenant by domain
|
||||||
let tenant = await centralPrisma.tenant.findUnique({
|
const domainRecord = await centralPrisma.domain.findUnique({
|
||||||
where: { id: tenantIdOrSlug },
|
where: { domain },
|
||||||
|
include: { tenant: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!domainRecord) {
|
||||||
tenant = await centralPrisma.tenant.findUnique({
|
throw new Error(`Domain ${domain} not found`);
|
||||||
where: { slug: tenantIdOrSlug },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenant = domainRecord.tenant;
|
||||||
|
this.logger.log(`Found tenant by domain: ${domain} -> ${tenant.name}`);
|
||||||
|
|
||||||
|
if (tenant.status !== 'active') {
|
||||||
|
throw new Error(`Tenant ${tenant.name} is not active`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connection and cache it
|
||||||
|
const tenantKnex = await this.createTenantConnection(tenant);
|
||||||
|
this.tenantConnections.set(cacheKey, tenantKnex);
|
||||||
|
|
||||||
|
return tenantKnex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant database connection by tenant ID (for central admin operations)
|
||||||
|
* This is used when central admin needs to access tenant databases
|
||||||
|
*/
|
||||||
|
async getTenantKnexById(tenantId: string): Promise<Knex> {
|
||||||
|
const cacheKey = `id:${tenantId}`;
|
||||||
|
|
||||||
|
// Check if we have a cached connection (no validation needed for ID-based lookups)
|
||||||
|
if (this.tenantConnections.has(cacheKey)) {
|
||||||
|
return this.tenantConnections.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
// Find tenant by ID
|
||||||
|
const tenant = await centralPrisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new Error(`Tenant ${tenantIdOrSlug} not found`);
|
throw new Error(`Tenant ${tenantId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tenant.status !== 'active') {
|
if (tenant.status !== 'active') {
|
||||||
throw new Error(`Tenant ${tenantIdOrSlug} is not active`);
|
throw new Error(`Tenant ${tenant.name} is not active`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Connecting to tenant database by ID: ${tenant.name}`);
|
||||||
|
|
||||||
|
// Create connection and cache it
|
||||||
|
const tenantKnex = await this.createTenantConnection(tenant);
|
||||||
|
this.tenantConnections.set(cacheKey, tenantKnex);
|
||||||
|
|
||||||
|
return tenantKnex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy method - delegates to domain-based lookup
|
||||||
|
* @deprecated Use getTenantKnexByDomain or getTenantKnexById instead
|
||||||
|
*/
|
||||||
|
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
|
||||||
|
// Assume it's a domain if it contains a dot
|
||||||
|
return this.getTenantKnexByDomain(tenantIdOrSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Knex connection to a tenant database
|
||||||
|
*/
|
||||||
|
private async createTenantConnection(tenant: any): Promise<Knex> {
|
||||||
// Decrypt password
|
// Decrypt password
|
||||||
const decryptedPassword = this.decryptPassword(tenant.dbPassword);
|
const decryptedPassword = this.decryptPassword(tenant.dbPassword);
|
||||||
|
|
||||||
@@ -64,7 +148,6 @@ export class TenantDatabaseService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tenantConnections.set(tenantIdOrSlug, tenantKnex);
|
|
||||||
return tenantKnex;
|
return tenantKnex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +169,36 @@ export class TenantDatabaseService {
|
|||||||
return domainRecord.tenant;
|
return domainRecord.tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve tenant by ID or slug
|
||||||
|
* Tries ID first, then falls back to slug
|
||||||
|
*/
|
||||||
|
async resolveTenantId(idOrSlug: string): Promise<string> {
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
// Try by ID first
|
||||||
|
let tenant = await centralPrisma.tenant.findUnique({
|
||||||
|
where: { id: idOrSlug },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If not found, try by slug
|
||||||
|
if (!tenant) {
|
||||||
|
tenant = await centralPrisma.tenant.findUnique({
|
||||||
|
where: { slug: idOrSlug },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error(`Tenant ${idOrSlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant.status !== 'active') {
|
||||||
|
throw new Error(`Tenant ${tenant.name} is not active`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant.id;
|
||||||
|
}
|
||||||
|
|
||||||
async disconnectTenant(tenantId: string) {
|
async disconnectTenant(tenantId: string) {
|
||||||
const connection = this.tenantConnections.get(tenantId);
|
const connection = this.tenantConnections.get(tenantId);
|
||||||
if (connection) {
|
if (connection) {
|
||||||
@@ -129,4 +242,26 @@ export class TenantDatabaseService {
|
|||||||
decrypted += decipher.final('utf8');
|
decrypted += decipher.final('utf8');
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt integrations config JSON object
|
||||||
|
* @param config - Plain object containing integration credentials
|
||||||
|
* @returns Encrypted JSON string
|
||||||
|
*/
|
||||||
|
encryptIntegrationsConfig(config: any): string {
|
||||||
|
if (!config) return null;
|
||||||
|
const jsonString = JSON.stringify(config);
|
||||||
|
return this.encryptPassword(jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt integrations config JSON string
|
||||||
|
* @param encryptedConfig - Encrypted JSON string
|
||||||
|
* @returns Plain object with integration credentials
|
||||||
|
*/
|
||||||
|
decryptIntegrationsConfig(encryptedConfig: string): any {
|
||||||
|
if (!encryptedConfig) return null;
|
||||||
|
const decrypted = this.decryptPassword(encryptedConfig);
|
||||||
|
return JSON.parse(decrypted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export class TenantProvisioningService {
|
|||||||
* Seed default data for new tenant
|
* Seed default data for new tenant
|
||||||
*/
|
*/
|
||||||
private async seedDefaultData(tenantId: string) {
|
private async seedDefaultData(tenantId: string) {
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId);
|
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create default roles
|
// Create default roles
|
||||||
|
|||||||
155
backend/src/tenant/tenant.controller.ts
Normal file
155
backend/src/tenant/tenant.controller.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
|
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||||
|
import { TenantId } from './tenant.decorator';
|
||||||
|
|
||||||
|
@Controller('tenant')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TenantController {
|
||||||
|
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get integrations configuration for the current tenant
|
||||||
|
*/
|
||||||
|
@Get('integrations')
|
||||||
|
async getIntegrationsConfig(@TenantId() domain: string) {
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
|
||||||
|
// Look up tenant by domain
|
||||||
|
const domainRecord = await centralPrisma.domain.findUnique({
|
||||||
|
where: { domain },
|
||||||
|
include: { tenant: { select: { id: true, integrationsConfig: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domainRecord?.tenant || !domainRecord.tenant.integrationsConfig) {
|
||||||
|
return { data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the config
|
||||||
|
const config = this.tenantDbService.decryptIntegrationsConfig(
|
||||||
|
domainRecord.tenant.integrationsConfig as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return config with sensitive fields masked
|
||||||
|
const maskedConfig = this.maskSensitiveFields(config);
|
||||||
|
|
||||||
|
return { data: maskedConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update integrations configuration for the current tenant
|
||||||
|
*/
|
||||||
|
@Put('integrations')
|
||||||
|
async updateIntegrationsConfig(
|
||||||
|
@TenantId() domain: string,
|
||||||
|
@Body() body: { integrationsConfig: any },
|
||||||
|
) {
|
||||||
|
const { integrationsConfig } = body;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new Error('Domain is missing from request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up tenant by domain
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
const domainRecord = await centralPrisma.domain.findUnique({
|
||||||
|
where: { domain },
|
||||||
|
include: { tenant: { select: { id: true, integrationsConfig: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domainRecord?.tenant) {
|
||||||
|
throw new Error(`Tenant with domain ${domain} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing config to preserve masked values
|
||||||
|
let finalConfig = integrationsConfig;
|
||||||
|
if (domainRecord.tenant.integrationsConfig) {
|
||||||
|
const existingConfig = this.tenantDbService.decryptIntegrationsConfig(
|
||||||
|
domainRecord.tenant.integrationsConfig as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace masked values with actual values from existing config
|
||||||
|
finalConfig = this.unmaskConfig(integrationsConfig, existingConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the config
|
||||||
|
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
|
||||||
|
finalConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
await centralPrisma.tenant.update({
|
||||||
|
where: { id: domainRecord.tenant.id },
|
||||||
|
data: {
|
||||||
|
integrationsConfig: encryptedConfig as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Integrations configuration updated successfully',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmask config by replacing masked values with actual values from existing config
|
||||||
|
*/
|
||||||
|
private unmaskConfig(newConfig: any, existingConfig: any): any {
|
||||||
|
const result = { ...newConfig };
|
||||||
|
|
||||||
|
// Unmask Twilio credentials
|
||||||
|
if (result.twilio && existingConfig.twilio) {
|
||||||
|
if (result.twilio.authToken === '••••••••' && existingConfig.twilio.authToken) {
|
||||||
|
result.twilio.authToken = existingConfig.twilio.authToken;
|
||||||
|
}
|
||||||
|
if (result.twilio.apiSecret === '••••••••' && existingConfig.twilio.apiSecret) {
|
||||||
|
result.twilio.apiSecret = existingConfig.twilio.apiSecret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmask OpenAI credentials
|
||||||
|
if (result.openai && existingConfig.openai) {
|
||||||
|
if (result.openai.apiKey === '••••••••' && existingConfig.openai.apiKey) {
|
||||||
|
result.openai.apiKey = existingConfig.openai.apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive fields for API responses
|
||||||
|
*/
|
||||||
|
private maskSensitiveFields(config: any): any {
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const masked = { ...config };
|
||||||
|
|
||||||
|
// Mask Twilio credentials
|
||||||
|
if (masked.twilio) {
|
||||||
|
masked.twilio = {
|
||||||
|
...masked.twilio,
|
||||||
|
authToken: masked.twilio.authToken ? '••••••••' : '',
|
||||||
|
apiSecret: masked.twilio.apiSecret ? '••••••••' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask OpenAI credentials
|
||||||
|
if (masked.openai) {
|
||||||
|
masked.openai = {
|
||||||
|
...masked.openai,
|
||||||
|
apiKey: masked.openai.apiKey ? '••••••••' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,14 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
// Extract subdomain from hostname
|
// Extract subdomain from hostname
|
||||||
const host = req.headers.host || '';
|
const host = req.headers.host || '';
|
||||||
const hostname = host.split(':')[0]; // Remove port if present
|
const hostname = host.split(':')[0]; // Remove port if present
|
||||||
const parts = hostname.split('.');
|
|
||||||
|
|
||||||
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}`);
|
// Check Origin header to get frontend subdomain (for API calls)
|
||||||
|
const origin = req.headers.origin as string;
|
||||||
|
const referer = req.headers.referer as string;
|
||||||
|
|
||||||
|
let parts = hostname.split('.');
|
||||||
|
|
||||||
|
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`);
|
||||||
|
|
||||||
// For local development, accept x-tenant-id header
|
// For local development, accept x-tenant-id header
|
||||||
let tenantId = req.headers['x-tenant-id'] as string;
|
let tenantId = req.headers['x-tenant-id'] as string;
|
||||||
@@ -27,12 +32,26 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
|
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
|
||||||
|
|
||||||
// If x-tenant-id is explicitly provided, use it directly
|
// Try to extract subdomain from Origin header first (for API calls from frontend)
|
||||||
if (tenantId) {
|
if (origin) {
|
||||||
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
|
try {
|
||||||
(req as any).tenantId = tenantId;
|
const originUrl = new URL(origin);
|
||||||
next();
|
const originHost = originUrl.hostname;
|
||||||
return;
|
parts = originHost.split('.');
|
||||||
|
this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to parse origin: ${origin}`);
|
||||||
|
}
|
||||||
|
} else if (referer && !tenantId) {
|
||||||
|
// Fallback to Referer if no Origin
|
||||||
|
try {
|
||||||
|
const refererUrl = new URL(referer);
|
||||||
|
const refererHost = refererUrl.hostname;
|
||||||
|
parts = refererHost.split('.');
|
||||||
|
this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to parse referer: ${referer}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
|
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
|
||||||
@@ -51,6 +70,36 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
this.logger.log(`Extracted subdomain: ${subdomain}`);
|
this.logger.log(`Extracted subdomain: ${subdomain}`);
|
||||||
|
|
||||||
|
// Always attach subdomain to request if present
|
||||||
|
if (subdomain) {
|
||||||
|
(req as any).subdomain = subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If x-tenant-id is explicitly provided, use it directly but still keep subdomain
|
||||||
|
if (tenantId) {
|
||||||
|
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
|
||||||
|
(req as any).tenantId = tenantId;
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always attach subdomain to request if present
|
||||||
|
if (subdomain) {
|
||||||
|
(req as any).subdomain = subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a central subdomain
|
||||||
|
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
|
||||||
|
const isCentral = subdomain && centralSubdomains.includes(subdomain);
|
||||||
|
|
||||||
|
// If it's a central subdomain, skip tenant resolution
|
||||||
|
if (isCentral) {
|
||||||
|
this.logger.log(`Central subdomain detected: ${subdomain}, skipping tenant resolution`);
|
||||||
|
(req as any).subdomain = subdomain;
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get tenant by subdomain if available
|
// Get tenant by subdomain if available
|
||||||
if (subdomain) {
|
if (subdomain) {
|
||||||
try {
|
try {
|
||||||
@@ -72,9 +121,6 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
// Attach tenant info to request object
|
// Attach tenant info to request object
|
||||||
(req as any).tenantId = tenantId;
|
(req as any).tenantId = tenantId;
|
||||||
if (subdomain) {
|
|
||||||
(req as any).subdomain = subdomain;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { TenantMiddleware } from './tenant.middleware';
|
|||||||
import { TenantDatabaseService } from './tenant-database.service';
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||||
|
import { CentralAdminController } from './central-admin.controller';
|
||||||
|
import { TenantController } from './tenant.controller';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [TenantProvisioningController],
|
controllers: [TenantProvisioningController, CentralAdminController, TenantController],
|
||||||
providers: [
|
providers: [
|
||||||
TenantDatabaseService,
|
TenantDatabaseService,
|
||||||
TenantProvisioningService,
|
TenantProvisioningService,
|
||||||
|
|||||||
214
backend/src/voice/audio-converter.service.ts
Normal file
214
backend/src/voice/audio-converter.service.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio format converter for Twilio <-> OpenAI audio streaming
|
||||||
|
*
|
||||||
|
* Twilio Media Streams format:
|
||||||
|
* - Codec: μ-law (G.711)
|
||||||
|
* - Sample rate: 8kHz
|
||||||
|
* - Encoding: base64
|
||||||
|
* - Chunk size: 20ms (160 bytes)
|
||||||
|
*
|
||||||
|
* OpenAI Realtime API format:
|
||||||
|
* - Codec: PCM16
|
||||||
|
* - Sample rate: 24kHz
|
||||||
|
* - Encoding: base64
|
||||||
|
* - Mono channel
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AudioConverterService {
|
||||||
|
private readonly logger = new Logger(AudioConverterService.name);
|
||||||
|
|
||||||
|
// μ-law decode lookup table
|
||||||
|
private readonly MULAW_DECODE_TABLE = this.buildMuLawDecodeTable();
|
||||||
|
|
||||||
|
// μ-law encode lookup table
|
||||||
|
private readonly MULAW_ENCODE_TABLE = this.buildMuLawEncodeTable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build μ-law to linear PCM16 decode table
|
||||||
|
*/
|
||||||
|
private buildMuLawDecodeTable(): Int16Array {
|
||||||
|
const table = new Int16Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
const mulaw = ~i;
|
||||||
|
const exponent = (mulaw >> 4) & 0x07;
|
||||||
|
const mantissa = mulaw & 0x0f;
|
||||||
|
let sample = (mantissa << 3) + 0x84;
|
||||||
|
sample <<= exponent;
|
||||||
|
sample -= 0x84;
|
||||||
|
if ((mulaw & 0x80) === 0) {
|
||||||
|
sample = -sample;
|
||||||
|
}
|
||||||
|
table[i] = sample;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build linear PCM16 to μ-law encode table
|
||||||
|
*/
|
||||||
|
private buildMuLawEncodeTable(): Uint8Array {
|
||||||
|
const table = new Uint8Array(65536);
|
||||||
|
for (let i = 0; i < 65536; i++) {
|
||||||
|
const sample = (i - 32768);
|
||||||
|
const sign = sample < 0 ? 0x80 : 0x00;
|
||||||
|
const magnitude = Math.abs(sample);
|
||||||
|
|
||||||
|
// Add bias
|
||||||
|
let biased = magnitude + 0x84;
|
||||||
|
|
||||||
|
// Find exponent
|
||||||
|
let exponent = 7;
|
||||||
|
for (let exp = 0; exp < 8; exp++) {
|
||||||
|
if (biased <= (0xff << exp)) {
|
||||||
|
exponent = exp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract mantissa
|
||||||
|
const mantissa = (biased >> (exponent + 3)) & 0x0f;
|
||||||
|
|
||||||
|
// Combine sign, exponent, mantissa
|
||||||
|
const mulaw = ~(sign | (exponent << 4) | mantissa);
|
||||||
|
table[i] = mulaw & 0xff;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode μ-law audio to linear PCM16
|
||||||
|
* @param mulawData - Buffer containing μ-law encoded audio
|
||||||
|
* @returns Buffer containing PCM16 audio (16-bit little-endian)
|
||||||
|
*/
|
||||||
|
decodeMuLaw(mulawData: Buffer): Buffer {
|
||||||
|
const pcm16 = Buffer.allocUnsafe(mulawData.length * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < mulawData.length; i++) {
|
||||||
|
const sample = this.MULAW_DECODE_TABLE[mulawData[i]];
|
||||||
|
pcm16.writeInt16LE(sample, i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pcm16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode linear PCM16 to μ-law
|
||||||
|
* @param pcm16Data - Buffer containing PCM16 audio (16-bit little-endian)
|
||||||
|
* @returns Buffer containing μ-law encoded audio
|
||||||
|
*/
|
||||||
|
encodeMuLaw(pcm16Data: Buffer): Buffer {
|
||||||
|
const mulaw = Buffer.allocUnsafe(pcm16Data.length / 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < pcm16Data.length; i += 2) {
|
||||||
|
const sample = pcm16Data.readInt16LE(i);
|
||||||
|
const index = (sample + 32768) & 0xffff;
|
||||||
|
mulaw[i / 2] = this.MULAW_ENCODE_TABLE[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return mulaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resample audio from 8kHz to 24kHz (linear interpolation)
|
||||||
|
* @param pcm16Data - Buffer containing 8kHz PCM16 audio
|
||||||
|
* @returns Buffer containing 24kHz PCM16 audio
|
||||||
|
*/
|
||||||
|
resample8kTo24k(pcm16Data: Buffer): Buffer {
|
||||||
|
const inputSamples = pcm16Data.length / 2;
|
||||||
|
const outputSamples = Math.floor(inputSamples * 3); // 8k * 3 = 24k
|
||||||
|
const output = Buffer.allocUnsafe(outputSamples * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < outputSamples; i++) {
|
||||||
|
const srcIndex = i / 3;
|
||||||
|
const srcIndexFloor = Math.floor(srcIndex);
|
||||||
|
const srcIndexCeil = Math.min(srcIndexFloor + 1, inputSamples - 1);
|
||||||
|
const fraction = srcIndex - srcIndexFloor;
|
||||||
|
|
||||||
|
const sample1 = pcm16Data.readInt16LE(srcIndexFloor * 2);
|
||||||
|
const sample2 = pcm16Data.readInt16LE(srcIndexCeil * 2);
|
||||||
|
|
||||||
|
// Linear interpolation
|
||||||
|
const interpolated = Math.round(sample1 + (sample2 - sample1) * fraction);
|
||||||
|
output.writeInt16LE(interpolated, i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resample audio from 24kHz to 8kHz (decimation with averaging)
|
||||||
|
* @param pcm16Data - Buffer containing 24kHz PCM16 audio
|
||||||
|
* @returns Buffer containing 8kHz PCM16 audio
|
||||||
|
*/
|
||||||
|
resample24kTo8k(pcm16Data: Buffer): Buffer {
|
||||||
|
const inputSamples = pcm16Data.length / 2;
|
||||||
|
const outputSamples = Math.floor(inputSamples / 3); // 24k / 3 = 8k
|
||||||
|
const output = Buffer.allocUnsafe(outputSamples * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < outputSamples; i++) {
|
||||||
|
// Average 3 samples for anti-aliasing
|
||||||
|
const idx1 = Math.min(i * 3, inputSamples - 1);
|
||||||
|
const idx2 = Math.min(i * 3 + 1, inputSamples - 1);
|
||||||
|
const idx3 = Math.min(i * 3 + 2, inputSamples - 1);
|
||||||
|
|
||||||
|
const sample1 = pcm16Data.readInt16LE(idx1 * 2);
|
||||||
|
const sample2 = pcm16Data.readInt16LE(idx2 * 2);
|
||||||
|
const sample3 = pcm16Data.readInt16LE(idx3 * 2);
|
||||||
|
|
||||||
|
const averaged = Math.round((sample1 + sample2 + sample3) / 3);
|
||||||
|
output.writeInt16LE(averaged, i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Twilio μ-law 8kHz to OpenAI PCM16 24kHz
|
||||||
|
* @param twilioBase64 - Base64-encoded μ-law audio from Twilio
|
||||||
|
* @returns Base64-encoded PCM16 24kHz audio for OpenAI
|
||||||
|
*/
|
||||||
|
twilioToOpenAI(twilioBase64: string): string {
|
||||||
|
try {
|
||||||
|
// Decode base64
|
||||||
|
const mulawBuffer = Buffer.from(twilioBase64, 'base64');
|
||||||
|
|
||||||
|
// μ-law -> PCM16
|
||||||
|
const pcm16_8k = this.decodeMuLaw(mulawBuffer);
|
||||||
|
|
||||||
|
// 8kHz -> 24kHz
|
||||||
|
const pcm16_24k = this.resample8kTo24k(pcm16_8k);
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
return pcm16_24k.toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error converting Twilio to OpenAI audio', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert OpenAI PCM16 24kHz to Twilio μ-law 8kHz
|
||||||
|
* @param openaiBase64 - Base64-encoded PCM16 24kHz audio from OpenAI
|
||||||
|
* @returns Base64-encoded μ-law 8kHz audio for Twilio
|
||||||
|
*/
|
||||||
|
openAIToTwilio(openaiBase64: string): string {
|
||||||
|
try {
|
||||||
|
// Decode base64
|
||||||
|
const pcm16_24k = Buffer.from(openaiBase64, 'base64');
|
||||||
|
|
||||||
|
// 24kHz -> 8kHz
|
||||||
|
const pcm16_8k = this.resample24kTo8k(pcm16_24k);
|
||||||
|
|
||||||
|
// PCM16 -> μ-law
|
||||||
|
const mulawBuffer = this.encodeMuLaw(pcm16_8k);
|
||||||
|
|
||||||
|
// Encode to base64
|
||||||
|
return mulawBuffer.toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error converting OpenAI to Twilio audio', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/voice/dto/call-event.dto.ts
Normal file
25
backend/src/voice/dto/call-event.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface CallEventDto {
|
||||||
|
callSid: string;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
fromNumber: string;
|
||||||
|
toNumber: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DtmfEventDto {
|
||||||
|
callSid: string;
|
||||||
|
digit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptEventDto {
|
||||||
|
callSid: string;
|
||||||
|
transcript: string;
|
||||||
|
isFinal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSuggestionDto {
|
||||||
|
callSid: string;
|
||||||
|
suggestion: string;
|
||||||
|
type: 'response' | 'action' | 'insight';
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
10
backend/src/voice/dto/initiate-call.dto.ts
Normal file
10
backend/src/voice/dto/initiate-call.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class InitiateCallDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^\+?[1-9]\d{1,14}$/, {
|
||||||
|
message: 'Invalid phone number format (use E.164 format)',
|
||||||
|
})
|
||||||
|
toNumber: string;
|
||||||
|
}
|
||||||
20
backend/src/voice/interfaces/integration-config.interface.ts
Normal file
20
backend/src/voice/interfaces/integration-config.interface.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface TwilioConfig {
|
||||||
|
accountSid: string;
|
||||||
|
authToken: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
apiKey?: string; // API Key SID for generating access tokens
|
||||||
|
apiSecret?: string; // API Key Secret
|
||||||
|
twimlAppSid?: string; // TwiML App SID for Voice SDK
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIConfig {
|
||||||
|
apiKey: string;
|
||||||
|
assistantId?: string;
|
||||||
|
model?: string;
|
||||||
|
voice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationsConfig {
|
||||||
|
twilio?: TwilioConfig;
|
||||||
|
openai?: OpenAIConfig;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user