12 Commits

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

View File

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

View File

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

83
DEBUG_INCOMING_CALL.md Normal file
View File

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

173
SOFTPHONE_AI_ASSISTANT.md Normal file
View File

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

23
backend/.env.example Normal file
View File

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

View File

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

19
backend/knexfile.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1519
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
provider = "mysql"

View File

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

View File

@@ -1,39 +1,22 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Tenant-specific database schema
// This schema is applied to each tenant's database
// NOTE: Each tenant has its own database, so there is NO tenantId column in these tables
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
output = "../node_modules/.prisma/tenant"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// Multi-tenancy
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
objectDefinitions ObjectDefinition[]
accounts Account[]
apps App[]
roles Role[]
permissions Permission[]
@@map("tenants")
url = env("TENANT_DATABASE_URL")
}
// User & Auth
model User {
id String @id @default(uuid())
tenantId String
email String
email String @unique
password String
firstName String?
lastName String?
@@ -41,48 +24,39 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
userRoles UserRole[]
accounts Account[]
@@unique([tenantId, email])
@@index([tenantId])
@@map("users")
}
// RBAC - Spatie-like
model Role {
id String @id @default(uuid())
tenantId String
name String
guardName String @default("api")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
userRoles UserRole[]
rolePermissions RolePermission[]
@@unique([tenantId, name, guardName])
@@index([tenantId])
@@unique([name, guardName])
@@map("roles")
}
model Permission {
id String @id @default(uuid())
tenantId String
name String
guardName String @default("api")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
rolePermissions RolePermission[]
@@unique([tenantId, name, guardName])
@@index([tenantId])
@@unique([name, guardName])
@@map("permissions")
}
@@ -119,75 +93,98 @@ model RolePermission {
// Object Definition (Metadata)
model ObjectDefinition {
id String @id @default(uuid())
tenantId String
apiName String
apiName String @unique
label String
pluralLabel String?
description String? @db.Text
isSystem Boolean @default(false)
tableName String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isCustom Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
fields FieldDefinition[]
pages AppPage[]
@@unique([tenantId, apiName])
@@index([tenantId])
@@map("object_definitions")
}
model FieldDefinition {
id String @id @default(uuid())
objectId String
apiName String
label String
type String // text, number, boolean, date, datetime, lookup, picklist, etc.
description String? @db.Text
isRequired Boolean @default(false)
isUnique Boolean @default(false)
isReadonly Boolean @default(false)
isLookup Boolean @default(false)
referenceTo String? // objectApiName for lookup fields
defaultValue String?
options Json? // for picklist fields
validationRules Json? // custom validation rules
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
objectDefinitionId String
apiName String
label String
type String // String, Number, Date, Boolean, Reference, etc.
length Int?
precision Int?
scale Int?
referenceObject String?
defaultValue String? @db.Text
description String? @db.Text
isRequired Boolean @default(false)
isUnique Boolean @default(false)
isSystem Boolean @default(false)
isCustom Boolean @default(true)
displayOrder Int @default(0)
uiMetadata Json? @map("ui_metadata")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
object ObjectDefinition @relation(fields: [objectId], references: [id], onDelete: Cascade)
object ObjectDefinition @relation(fields: [objectDefinitionId], references: [id], onDelete: Cascade)
@@unique([objectId, apiName])
@@index([objectId])
@@unique([objectDefinitionId, apiName])
@@index([objectDefinitionId])
@@map("field_definitions")
}
// Example static object: Account
model Account {
id String @id @default(uuid())
tenantId String
name String
status String @default("active")
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
contacts Contact[]
@@index([tenantId])
@@index([ownerId])
@@map("accounts")
}
model Contact {
id String @id @default(uuid())
firstName String
lastName String
accountId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@index([accountId])
@@map("contacts")
}
model ContactDetail {
id String @id @default(uuid())
relatedObjectType String
relatedObjectId String
detailType String
label String?
value String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([relatedObjectType, relatedObjectId])
@@map("contact_details")
}
// Application Builder
model App {
id String @id @default(uuid())
tenantId String
slug String
slug String @unique
label String
description String? @db.Text
icon String?
@@ -195,11 +192,8 @@ model App {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
pages AppPage[]
@@unique([tenantId, slug])
@@index([tenantId])
@@map("apps")
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 {}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,337 @@
import { Injectable } from '@nestjs/common';
import { FieldDefinition } from '../models/field-definition.model';
export interface FieldConfigDTO {
id: string;
apiName: string;
label: string;
type: string;
placeholder?: string;
helpText?: string;
defaultValue?: any;
isRequired?: boolean;
isReadOnly?: boolean;
showOnList?: boolean;
showOnDetail?: boolean;
showOnEdit?: boolean;
sortable?: boolean;
options?: Array<{ label: string; value: any }>;
rows?: number;
min?: number;
max?: number;
step?: number;
accept?: string;
relationObject?: string;
relationObjects?: string[];
relationDisplayField?: string;
relationTypeField?: string;
format?: string;
prefix?: string;
suffix?: string;
validationRules?: Array<{
type: string;
value?: any;
message?: string;
}>;
dependsOn?: string[];
computedValue?: string;
}
export interface ObjectDefinitionDTO {
id: string;
apiName: string;
label: string;
pluralLabel?: string;
description?: string;
isSystem: boolean;
fields: FieldConfigDTO[];
relatedLists?: Array<{
title: string;
relationName: string;
objectApiName: string;
fields: FieldConfigDTO[];
canCreate?: boolean;
createRoute?: string;
}>;
}
@Injectable()
export class FieldMapperService {
/**
* Convert a field definition from the database to a frontend-friendly FieldConfig
*/
mapFieldToDTO(field: any): FieldConfigDTO {
// Parse ui_metadata if it's a JSON string or object
let uiMetadata: any = {};
const metadataField = field.ui_metadata || field.uiMetadata;
if (metadataField) {
if (typeof metadataField === 'string') {
try {
uiMetadata = JSON.parse(metadataField);
} catch (e) {
uiMetadata = {};
}
} else {
uiMetadata = metadataField;
}
}
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
return {
id: field.id,
apiName: field.apiName,
label: field.label,
type: frontendType,
// Display properties
placeholder: uiMetadata.placeholder || field.description,
helpText: uiMetadata.helpText || field.description,
defaultValue: field.defaultValue,
// Validation
isRequired: field.isRequired || false,
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
// View visibility
showOnList: uiMetadata.showOnList !== false,
showOnDetail: uiMetadata.showOnDetail !== false,
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
sortable: uiMetadata.sortable !== false,
// Field type specific options
options: uiMetadata.options,
rows: uiMetadata.rows,
min: uiMetadata.min,
max: uiMetadata.max,
step: uiMetadata.step,
accept: uiMetadata.accept,
relationObject: field.referenceObject,
relationObjects: uiMetadata.relationObjects,
// For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
relationTypeField: uiMetadata.relationTypeField,
// Formatting
format: uiMetadata.format,
prefix: uiMetadata.prefix,
suffix: uiMetadata.suffix,
// Validation rules
validationRules: this.buildValidationRules(field, uiMetadata),
// Advanced
dependsOn: uiMetadata.dependsOn,
computedValue: uiMetadata.computedValue,
};
}
/**
* Map database field type to frontend FieldType enum
*/
private mapFieldType(dbType: string): string {
const typeMap: Record<string, string> = {
'string': 'text',
'text': 'textarea',
'integer': 'number',
'decimal': 'number',
'boolean': 'boolean',
'date': 'date',
'datetime': 'datetime',
'time': 'time',
'email': 'email',
'url': 'url',
'phone': 'text',
'picklist': 'select',
'multipicklist': 'multiSelect',
'lookup': 'belongsTo',
'master-detail': 'belongsTo',
'currency': 'currency',
'percent': 'number',
'textarea': 'textarea',
'richtext': 'markdown',
'file': 'file',
'image': 'image',
'json': 'json',
};
return typeMap[dbType.toLowerCase()] || 'text';
}
/**
* Build validation rules array
*/
private buildValidationRules(field: any, uiMetadata: any): Array<any> {
const rules = uiMetadata.validationRules || [];
// Add required rule if field is required and not already in rules
if (field.isRequired && !rules.some(r => r.type === 'required')) {
rules.unshift({
type: 'required',
message: `${field.label} is required`,
});
}
// Add length validation for string fields
if (field.length && field.type === 'string') {
rules.push({
type: 'max',
value: field.length,
message: `${field.label} must not exceed ${field.length} characters`,
});
}
// Add email validation
if (field.type === 'email' && !rules.some(r => r.type === 'email')) {
rules.push({
type: 'email',
message: `${field.label} must be a valid email address`,
});
}
// Add URL validation
if (field.type === 'url' && !rules.some(r => r.type === 'url')) {
rules.push({
type: 'url',
message: `${field.label} must be a valid URL`,
});
}
return rules;
}
/**
* Convert object definition with fields to DTO
*/
mapObjectDefinitionToDTO(objectDef: any): ObjectDefinitionDTO {
return {
id: objectDef.id,
apiName: objectDef.apiName,
label: objectDef.label,
pluralLabel: objectDef.pluralLabel,
description: objectDef.description,
isSystem: objectDef.isSystem || false,
fields: (objectDef.fields || [])
.filter((f: any) => f.isActive !== false)
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map((f: any) => this.mapFieldToDTO(f)),
relatedLists: (objectDef.relatedLists || []).map((list: any) => ({
title: list.title,
relationName: list.relationName,
objectApiName: list.objectApiName,
fields: (list.fields || [])
.filter((f: any) => f.isActive !== false)
.map((f: any) => this.mapFieldToDTO(f))
.filter((f: any) => f.showOnList !== false),
canCreate: list.canCreate,
createRoute: list.createRoute,
})),
};
}
/**
* Generate default UI metadata for a field type
*/
generateDefaultUIMetadata(fieldType: string): any {
const defaults: Record<string, any> = {
text: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
textarea: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
rows: 4,
},
number: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
currency: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
prefix: '$',
step: 0.01,
},
boolean: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
date: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
format: 'yyyy-MM-dd',
},
datetime: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true,
format: 'yyyy-MM-dd HH:mm:ss',
},
email: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [{ type: 'email' }],
},
url: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
validationRules: [{ type: 'url' }],
},
select: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
options: [],
},
multiSelect: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
options: [],
},
image: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
accept: 'image/*',
},
file: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
},
};
return defaults[fieldType] || {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
};
}
}

View 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', ' ');
}
}

View 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`;
}
}

View 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();
}
}

View 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;
}
}
}

View 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' },
},
};
}
}

View File

@@ -2,10 +2,25 @@ import { Module } from '@nestjs/common';
import { ObjectService } from './object.service';
import { RuntimeObjectController } from './runtime-object.controller';
import { SetupObjectController } from './setup-object.controller';
import { SchemaManagementService } from './schema-management.service';
import { FieldMapperService } from './field-mapper.service';
import { TenantModule } from '../tenant/tenant.module';
import { MigrationModule } from '../migration/migration.module';
import { RbacModule } from '../rbac/rbac.module';
import { ModelRegistry } from './models/model.registry';
import { ModelService } from './models/model.service';
import { MeilisearchModule } from '../search/meilisearch.module';
@Module({
providers: [ObjectService],
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule],
providers: [
ObjectService,
SchemaManagementService,
FieldMapperService,
ModelRegistry,
ModelService,
],
controllers: [RuntimeObjectController, SetupObjectController],
exports: [ObjectService],
exports: [ObjectService, SchemaManagementService, FieldMapperService, ModelService],
})
export class ObjectModule {}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,312 @@
import { Injectable, Logger } from '@nestjs/common';
import { Knex } from 'knex';
import { ObjectDefinition } from '../models/object-definition.model';
import { FieldDefinition } from '../models/field-definition.model';
@Injectable()
export class SchemaManagementService {
private readonly logger = new Logger(SchemaManagementService.name);
/**
* Create a physical table for an object definition
*/
async createObjectTable(
knex: Knex,
objectDefinition: ObjectDefinition,
fields: FieldDefinition[],
) {
const tableName = this.getTableName(
objectDefinition.apiName,
objectDefinition.label,
objectDefinition.pluralLabel,
);
// Check if table already exists
const exists = await knex.schema.hasTable(tableName);
if (exists) {
throw new Error(`Table ${tableName} already exists`);
}
await knex.schema.createTable(tableName, (table) => {
// Standard fields
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.timestamps(true, true);
// Custom fields from field definitions
for (const field of fields) {
this.addFieldColumn(table, field);
}
});
this.logger.log(`Created table: ${tableName}`);
}
/**
* Add a new field to an existing object table
*/
async addFieldToTable(
knex: Knex,
objectApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
});
this.logger.log(`Added field ${field.apiName} to table ${tableName}`);
}
/**
* Remove a field from an existing object table
*/
async removeFieldFromTable(
knex: Knex,
objectApiName: string,
fieldApiName: string,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(fieldApiName);
});
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
}
/**
* Alter a field in an existing object table
* Handles safe updates like changing NOT NULL or constraints
* Warns about potentially destructive operations
*/
async alterFieldInTable(
knex: Knex,
objectApiName: string,
fieldApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
options?: {
skipTypeChange?: boolean; // Skip if type change would lose data
},
) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
const skipTypeChange = options?.skipTypeChange ?? true;
await knex.schema.alterTable(tableName, (table) => {
// Drop the existing column and recreate with new definition
// Note: This approach works for metadata changes, but type changes may need data migration
table.dropColumn(fieldApiName);
});
// Recreate the column with new definition
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
});
this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`);
}
/**
* Drop an object table
*/
async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.dropTableIfExists(tableName);
this.logger.log(`Dropped table: ${tableName}`);
}
/**
* Add a field column to a table builder
*/
private addFieldColumn(
table: Knex.CreateTableBuilder | Knex.AlterTableBuilder,
field: FieldDefinition,
) {
const columnName = field.apiName;
let column: Knex.ColumnBuilder;
switch (field.type) {
// Text types
case 'String':
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
column = table.string(columnName, field.length || 255);
break;
case 'Text':
case 'LONG_TEXT':
column = table.text(columnName);
break;
case 'PICKLIST':
case 'MULTI_PICKLIST':
column = table.string(columnName, 255);
break;
// Numeric types
case 'Number':
case 'NUMBER':
case 'CURRENCY':
case 'PERCENT':
if (field.scale && field.scale > 0) {
column = table.decimal(
columnName,
field.precision || 10,
field.scale,
);
} else {
column = table.integer(columnName);
}
break;
case 'Boolean':
case 'BOOLEAN':
column = table.boolean(columnName).defaultTo(false);
break;
// Date types
case 'Date':
case 'DATE':
column = table.date(columnName);
break;
case 'DateTime':
case 'DATE_TIME':
column = table.datetime(columnName);
break;
case 'TIME':
column = table.time(columnName);
break;
// Relationship types
case 'Reference':
case 'LOOKUP':
column = table.uuid(columnName);
if (field.referenceObject) {
const refTableName = this.getTableName(field.referenceObject);
column.references('id').inTable(refTableName).onDelete('SET NULL');
}
break;
// Email (legacy)
case 'Email':
column = table.string(columnName, 255);
break;
// Phone (legacy)
case 'Phone':
column = table.string(columnName, 50);
break;
// Url (legacy)
case 'Url':
column = table.string(columnName, 255);
break;
// File types
case 'FILE':
case 'IMAGE':
column = table.text(columnName); // Store file path or URL
break;
// JSON
case 'Json':
case 'JSON':
column = table.json(columnName);
break;
default:
throw new Error(`Unsupported field type: ${field.type}`);
}
if (field.isRequired) {
column.notNullable();
} else {
column.nullable();
}
if (field.isUnique) {
column.unique();
}
if (field.defaultValue) {
column.defaultTo(field.defaultValue);
}
return column;
}
/**
* Convert object API name to table name (convert to snake_case, pluralize)
*/
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
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;
}
/**
* Validate field definition before creating column
*/
validateFieldDefinition(field: FieldDefinition) {
if (!field.apiName || !field.label || !field.type) {
throw new Error('Field must have apiName, label, and type');
}
// Validate field name (alphanumeric + underscore, starts with letter)
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(field.apiName)) {
throw new Error(`Invalid field name: ${field.apiName}`);
}
// Validate reference field has referenceObject
if (field.type === 'Reference' && !field.referenceObject) {
throw new Error('Reference field must specify referenceObject');
}
// Validate numeric fields
if (field.type === 'Number') {
if (field.scale && field.scale > 0 && !field.precision) {
throw new Error('Decimal fields must specify precision');
}
}
return true;
}
}

View File

@@ -2,18 +2,27 @@ import {
Controller,
Get,
Post,
Patch,
Put,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { ObjectService } from './object.service';
import { FieldMapperService } from './field-mapper.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
@Controller('setup/objects')
@UseGuards(JwtAuthGuard)
export class SetupObjectController {
constructor(private objectService: ObjectService) {}
constructor(
private objectService: ObjectService,
private fieldMapperService: FieldMapperService,
private tenantDbService: TenantDatabaseService,
) {}
@Get()
async getObjectDefinitions(@TenantId() tenantId: string) {
@@ -25,7 +34,20 @@ export class SetupObjectController {
@TenantId() tenantId: 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')
async getObjectUIConfig(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
const objectDef = await this.objectService.getObjectDefinition(
tenantId,
objectApiName,
);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
}
@Post()
@@ -42,10 +64,93 @@ export class SetupObjectController {
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
return this.objectService.createFieldDefinition(
const field = await this.objectService.createFieldDefinition(
tenantId,
objectApiName,
data,
);
// Map the created field to frontend format
return this.fieldMapperService.mapFieldToDTO(field);
}
@Put(':objectApiName/fields/:fieldApiName')
async updateFieldDefinition(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('fieldApiName') fieldApiName: string,
@Body() data: any,
) {
const field = await this.objectService.updateFieldDefinition(
tenantId,
objectApiName,
fieldApiName,
data,
);
return this.fieldMapperService.mapFieldToDTO(field);
}
@Delete(':objectApiName/fields/:fieldApiName')
async deleteFieldDefinition(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('fieldApiName') fieldApiName: string,
) {
return this.objectService.deleteFieldDefinition(
tenantId,
objectApiName,
fieldApiName,
);
}
@Patch(':objectApiName')
async updateObjectDefinition(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
return this.objectService.updateObjectDefinition(tenantId, objectApiName, data);
}
@Get(':objectId/field-permissions')
async getFieldPermissions(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
) {
return this.objectService.getFieldPermissions(tenantId, objectId);
}
@Put(':objectId/field-permissions')
async updateFieldPermission(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
@Body() data: { roleId: string; fieldDefinitionId: string; canRead: boolean; canEdit: boolean },
) {
return this.objectService.updateFieldPermission(tenantId, data.roleId, data.fieldDefinitionId, data.canRead, data.canEdit);
}
@Get(':objectApiName/permissions/:roleId')
async getObjectPermissions(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('roleId') roleId: string,
) {
return this.objectService.getObjectPermissions(tenantId, objectApiName, roleId);
}
@Put(':objectApiName/permissions')
async updateObjectPermissions(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() data: {
roleId: string;
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canViewAll: boolean;
canModifyAll: boolean;
},
) {
return this.objectService.updateObjectPermissions(tenantId, objectApiName, data);
}
}

View File

@@ -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;
}

View 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);
}
}

View 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 {}

View 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' };
}
}

View File

@@ -0,0 +1,16 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
let centralPrisma: CentralPrismaClient;
export function getCentralPrisma(): CentralPrismaClient {
if (!centralPrisma) {
centralPrisma = new CentralPrismaClient();
}
return centralPrisma;
}
export async function disconnectCentral() {
if (centralPrisma) {
await centralPrisma.$disconnect();
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '.prisma/tenant';
@Injectable()
export class PrismaService

View 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));
}
}

View 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';
}
}
}

View 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;
}

View File

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

View 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;
}
}

View 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 };
}
}

View 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 };
}
}

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