Compare commits

...

31 Commits

Author SHA1 Message Date
Francisco Gaona
2c81fe1b0d WIP - twilio integration 2026-01-03 07:55:07 +01:00
Francisco Gaona
6593fecca7 WIP - saving expires at for sharing records 2025-12-31 05:01:27 +01:00
Francisco Gaona
75b7325cea WIP - use objection for record shares 2025-12-30 21:46:37 +01:00
Francisco Gaona
c50098a55c WIP - add and remove shares 2025-12-30 21:42:42 +01:00
Francisco Gaona
e73126bcb7 WIP - manually sharing records 2025-12-30 18:29:20 +01:00
Francisco Gaona
6c29d18696 WIP - more admin users and roles 2025-12-30 09:10:45 +01:00
Francisco Gaona
3fbc019083 WIP - admin users and roles 2025-12-30 09:06:42 +01:00
Francisco Gaona
3086f78d34 WIp - manage role permissions per object 2025-12-30 06:16:54 +01:00
Francisco Gaona
d15fc918d1 WIP - field level permission 2025-12-30 05:54:56 +01:00
Francisco Gaona
56c0c3838d WIP - permissions working as expected 2025-12-30 04:50:51 +01:00
Francisco Gaona
9ac69e30d0 WIP - better handling of viewAll modifyAll 2025-12-30 04:43:51 +01:00
Francisco Gaona
d37183ba45 WIp - fix displaying related model names in lookup fields 2025-12-30 04:22:56 +01:00
Francisco Gaona
b4bdeeb9f6 WIP - permissions progress 2025-12-30 03:26:50 +01:00
Francisco Gaona
f4143ab106 WIP - Fix objection and model registry 2025-12-27 06:08:25 +01:00
Francisco Gaona
516e132611 WIP - move docs 2025-12-24 21:46:05 +01:00
Francisco Gaona
c5305490c1 WIP - use objection and working lookup field to owner 2025-12-24 21:43:58 +01:00
Francisco Gaona
4520f94b69 WIP - using objection base model to handle objects operations 2025-12-24 20:18:43 +01:00
Francisco Gaona
e4f1ba96ad WIP - custom migrations when object is created 2025-12-24 19:54:13 +01:00
Francisco Gaona
52c0849de2 WIP - manage tenant users from central 2025-12-24 12:17:22 +01:00
Francisco Gaona
b9fa3bd008 WIP - improve login to tenants by domains 2025-12-24 11:42:44 +01:00
Francisco Gaona
2bc672e4c5 WIP - some fixes 2025-12-24 10:54:19 +01:00
Francisco Gaona
962c84e6d2 WIP - fix lookup field 2025-12-24 00:05:15 +01:00
Francisco Gaona
fc1bec4de7 WIP - related lists and look up field 2025-12-23 23:59:04 +01:00
Francisco Gaona
0275b96014 WIP - central operations 2025-12-23 23:38:45 +01:00
Francisco Gaona
e4f3bad971 WIp - fix login into central 2025-12-23 22:16:58 +01:00
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
264 changed files with 30294 additions and 637 deletions

View File

@@ -2,8 +2,12 @@ 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"
# JWT, multi-tenant hints, etc.
JWT_SECRET="devsecret"
TENANCY_STRATEGY="single-db"
CENTRAL_SUBDOMAINS="central,admin"

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

1137
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,36 +17,53 @@
"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": "^11.2.0",
"@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/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",
"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,66 +93,60 @@ 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])
@@index([tenantId])
@@index([ownerId])
@@map("accounts")
}
@@ -186,8 +154,7 @@ model Account {
// 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 +162,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

@@ -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,8 @@ 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';
@Module({
imports: [
@@ -18,6 +20,8 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
RbacModule,
ObjectModule,
AppBuilderModule,
PageLayoutModule,
VoiceModule,
],
})
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

@@ -9,7 +9,7 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
new FastifyAdapter({ logger: false }),
);
// Global validation pipe

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,18 @@
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
export class BaseModel extends Model {
static columnNameMappers = snakeCaseMappers();
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,86 @@
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
// 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,314 @@
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;
relationDisplayField?: 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[];
}
@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,
// For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
// 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)),
};
}
/**
* 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,201 @@
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`,
},
};
}
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> {
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' };
}
}
/**
* 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(/^_/, '');
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
}

View File

@@ -0,0 +1,68 @@
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);
}
/**
* Get a model from the registry
*/
getModel(apiName: string): ModelClass<BaseModel> {
const model = this.registry.get(apiName);
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);
}
/**
* 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.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,184 @@
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}`
);
}
}
}
// 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,24 @@ 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';
@Module({
providers: [ObjectService],
imports: [TenantModule, MigrationModule, RbacModule],
providers: [
ObjectService,
SchemaManagementService,
FieldMapperService,
ModelRegistry,
ModelService,
],
controllers: [RuntimeObjectController, SetupObjectController],
exports: [ObjectService],
exports: [ObjectService, SchemaManagementService, FieldMapperService, ModelService],
})
export class ObjectModule {}

View File

@@ -1,42 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CustomMigrationService } from '../migration/custom-migration.service';
import { ModelService } from './models/model.service';
import { AuthorizationService } from '../rbac/authorization.service';
import { ObjectDefinition } from '../models/object-definition.model';
import { FieldDefinition } from '../models/field-definition.model';
import { User } from '../models/user.model';
import { ObjectMetadata } from './models/dynamic-model.factory';
@Injectable()
export class ObjectService {
constructor(private prisma: PrismaService) {}
private readonly logger = new Logger(ObjectService.name);
constructor(
private tenantDbService: TenantDatabaseService,
private customMigrationService: CustomMigrationService,
private modelService: ModelService,
private authService: AuthorizationService,
) {}
// Setup endpoints - Object metadata management
async getObjectDefinitions(tenantId: string) {
return this.prisma.objectDefinition.findMany({
where: { tenantId },
include: {
fields: true,
},
orderBy: { label: 'asc' },
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const objects = await knex('object_definitions')
.select('object_definitions.*')
.orderBy('label', 'asc');
// Fetch app information for objects that have app_id
for (const obj of objects) {
if (obj.app_id) {
const app = await knex('apps')
.where({ id: obj.app_id })
.select('id', 'slug', 'label', 'description')
.first();
obj.app = app;
}
}
return objects;
}
async getObjectDefinition(tenantId: string, apiName: string) {
const obj = await this.prisma.objectDefinition.findUnique({
where: {
tenantId_apiName: {
tenantId,
apiName,
},
},
include: {
fields: {
where: { isActive: true },
orderBy: { label: 'asc' },
},
},
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const obj = await knex('object_definitions')
.where({ apiName })
.first();
if (!obj) {
throw new NotFoundException(`Object ${apiName} not found`);
}
return obj;
// Get fields for this object
const fields = await knex('field_definitions')
.where({ objectDefinitionId: obj.id })
.orderBy('label', 'asc');
// Normalize all fields to ensure system fields are properly marked
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
// Get app information if object belongs to an app
let app = null;
if (obj.app_id) {
app = await knex('apps')
.where({ id: obj.app_id })
.select('id', 'slug', 'label', 'description')
.first();
}
return {
...obj,
fields: normalizedFields,
app,
};
}
async createObjectDefinition(
@@ -49,13 +88,172 @@ export class ObjectService {
isSystem?: boolean;
},
) {
return this.prisma.objectDefinition.create({
data: {
tenantId,
...data,
tableName: `custom_${data.apiName.toLowerCase()}`,
},
// Resolve tenant ID in case a slug was passed
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Generate UUID for the new object
const objectId = require('crypto').randomUUID();
// Create the object definition record
await knex('object_definitions').insert({
id: objectId,
...data,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const objectDef = await knex('object_definitions').where({ id: objectId }).first();
// Create standard field definitions (only if they don't already exist)
const standardFields = [
{
apiName: 'ownerId',
label: 'Owner',
type: 'LOOKUP',
description: 'The user who owns this record',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: 'User',
isSystem: true,
isCustom: false,
},
{
apiName: 'name',
label: 'Name',
type: 'STRING',
description: 'The primary name field for this record',
isRequired: false, // Optional field
isUnique: false,
referenceObject: null,
isSystem: false,
isCustom: false,
},
{
apiName: 'created_at',
label: 'Created At',
type: 'DATE_TIME',
description: 'The timestamp when this record was created',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: null,
isSystem: true,
isCustom: false,
},
{
apiName: 'updated_at',
label: 'Updated At',
type: 'DATE_TIME',
description: 'The timestamp when this record was last updated',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: null,
isSystem: true,
isCustom: false,
},
];
// Insert standard field definitions that don't already exist
for (const field of standardFields) {
const existingField = await knex('field_definitions')
.where({
objectDefinitionId: objectDef.id,
apiName: field.apiName,
})
.first();
if (!existingField) {
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: objectDef.id,
...field,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
// For lookup fields, set ui_metadata with relationDisplayField
if (field.type === 'LOOKUP') {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: 'name',
});
}
await knex('field_definitions').insert(fieldData);
}
}
// Create a migration to create the table
const tableName = this.getTableName(data.apiName);
const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName);
try {
await this.customMigrationService.createAndExecuteMigration(
knex,
resolvedTenantId,
{
name: `create_${tableName}_table`,
description: `Create table for ${data.label} object`,
type: 'create_table',
sql: createTableSQL,
},
);
} catch (error) {
// Log the error but don't fail - migration is recorded for future retry
console.error(`Failed to execute table creation migration: ${error.message}`);
}
// Create and register the Objection model for this object
try {
const allFields = await knex('field_definitions')
.where({ objectDefinitionId: objectDef.id })
.select('apiName', 'label', 'type', 'isRequired', 'isUnique', 'referenceObject');
const objectMetadata: ObjectMetadata = {
apiName: data.apiName,
tableName,
fields: allFields.map((f: any) => ({
apiName: f.apiName,
label: f.label,
type: f.type,
isRequired: f.isRequired,
isUnique: f.isUnique,
referenceObject: f.referenceObject,
})),
relations: [],
};
await this.modelService.createModelForObject(resolvedTenantId, objectMetadata);
} catch (error) {
console.error(`Failed to create model for object ${data.apiName}:`, error.message);
}
return objectDef;
}
async updateObjectDefinition(
tenantId: string,
objectApiName: string,
data: Partial<{
label: string;
pluralLabel: string;
description: string;
orgWideDefault: 'private' | 'public_read' | 'public_read_write';
}>,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Update the object definition
await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.patch({
...data,
updatedAt: new Date(),
});
// Return updated object
return await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
}
async createFieldDefinition(
@@ -68,20 +266,176 @@ export class ObjectService {
description?: string;
isRequired?: boolean;
isUnique?: boolean;
isLookup?: boolean;
referenceTo?: string;
referenceObject?: string;
relationObject?: string;
relationDisplayField?: string;
defaultValue?: string;
options?: any;
},
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const obj = await this.getObjectDefinition(tenantId, objectApiName);
return this.prisma.fieldDefinition.create({
data: {
objectId: obj.id,
...data,
},
});
// Convert frontend type to database type
const dbFieldType = this.convertFrontendFieldType(data.type);
// Use relationObject if provided (alias for referenceObject)
const referenceObject = data.referenceObject || data.relationObject;
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: obj.id,
apiName: data.apiName,
label: data.label,
type: dbFieldType,
description: data.description,
isRequired: data.isRequired ?? false,
isUnique: data.isUnique ?? false,
referenceObject: referenceObject,
defaultValue: data.defaultValue,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
});
}
const [id] = await knex('field_definitions').insert(fieldData);
return knex('field_definitions').where({ id }).first();
}
// Helper to get table name from object definition
private getTableName(objectApiName: string): string {
// Convert CamelCase to snake_case and pluralize
// Account -> accounts, ContactPerson -> contact_persons
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (can be enhanced)
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase;
} else {
return snakeCase + 's';
}
}
/**
* Normalize field definition to ensure system fields are properly marked
*/
private normalizeField(field: any): any {
const systemFieldNames = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt'];
const isSystemField = systemFieldNames.includes(field.apiName);
return {
...field,
// Ensure system fields are marked correctly
isSystem: isSystemField ? true : field.isSystem,
isRequired: isSystemField ? false : field.isRequired,
isCustom: isSystemField ? false : field.isCustom ?? true,
};
}
/**
* Convert frontend field type to database field type
*/
private convertFrontendFieldType(frontendType: string): string {
const typeMap: Record<string, string> = {
'text': 'TEXT',
'textarea': 'LONG_TEXT',
'password': 'TEXT',
'email': 'EMAIL',
'number': 'NUMBER',
'currency': 'CURRENCY',
'percent': 'PERCENT',
'select': 'PICKLIST',
'multiSelect': 'MULTI_PICKLIST',
'boolean': 'BOOLEAN',
'date': 'DATE',
'datetime': 'DATE_TIME',
'time': 'TIME',
'url': 'URL',
'color': 'TEXT',
'json': 'JSON',
'belongsTo': 'LOOKUP',
'hasMany': 'LOOKUP',
'manyToMany': 'LOOKUP',
'markdown': 'LONG_TEXT',
'code': 'LONG_TEXT',
'file': 'FILE',
'image': 'IMAGE',
};
return typeMap[frontendType] || 'TEXT';
}
/**
* Ensure a model is registered for the given object.
* Delegates to ModelService which handles creating the model and all its dependencies.
*/
private async ensureModelRegistered(
tenantId: string,
objectApiName: string,
): Promise<void> {
// Provide a metadata fetcher function that the ModelService can use
const fetchMetadata = async (apiName: string): Promise<ObjectMetadata> => {
const objectDef = await this.getObjectDefinition(tenantId, apiName);
const tableName = this.getTableName(apiName);
// Build relations from lookup fields, but only for models that exist
const lookupFields = objectDef.fields.filter((f: any) =>
f.type === 'LOOKUP' && f.referenceObject
);
// Filter to only include relations where we can successfully resolve the target
const validRelations: any[] = [];
for (const field of lookupFields) {
// Check if the referenced object will be available
// We'll let the recursive registration attempt it, but won't include failed ones
validRelations.push({
name: field.apiName.replace(/Id$/, '').toLowerCase(),
type: 'belongsTo' as const,
targetObjectApiName: field.referenceObject,
fromColumn: field.apiName,
toColumn: 'id',
});
}
return {
apiName,
tableName,
fields: objectDef.fields.map((f: any) => ({
apiName: f.apiName,
label: f.label,
type: f.type,
isRequired: f.isRequired,
isUnique: f.isUnique,
referenceObject: f.referenceObject,
})),
relations: validRelations,
};
};
// Let the ModelService handle recursive model creation
try {
await this.modelService.ensureModelWithDependencies(
tenantId,
objectApiName,
fetchMetadata,
);
} catch (error) {
this.logger.warn(
`Failed to ensure model for ${objectApiName}: ${error.message}. Will fall back to manual hydration.`,
);
}
}
// Runtime endpoints - CRUD operations
@@ -91,20 +445,77 @@ export class ObjectService {
userId: string,
filters?: any,
) {
// For demonstration, using Account as example static object
if (objectApiName === 'Account') {
return this.prisma.account.findMany({
where: {
tenantId,
ownerId: userId, // Basic sharing rule
...filters,
},
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) {
throw new NotFoundException('User not found');
}
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
const tableName = this.getTableName(objectApiName);
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// For custom objects, you'd need dynamic query building
// This is a simplified version
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery(
query,
objectDefModel,
user,
'read',
knex,
);
// Build graph expression for lookup fields
const lookupFields = objectDefModel.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Apply additional filters
if (filters) {
query = query.where(filters);
}
const records = await query.select('*');
// Filter fields based on field-level permissions
const filteredRecords = await Promise.all(
records.map(record =>
this.authService.filterReadableFields(record, objectDefModel.fields, user)
)
);
return filteredRecords;
}
async getRecord(
@@ -113,23 +524,69 @@ export class ObjectService {
recordId: string,
userId: string,
) {
if (objectApiName === 'Account') {
const record = await this.prisma.account.findFirst({
where: {
id: recordId,
tenantId,
ownerId: userId,
},
});
if (!record) {
throw new NotFoundException('Record not found');
}
return record;
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) {
throw new NotFoundException('User not found');
}
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId });
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery(
query,
objectDefModel,
user,
'read',
knex,
);
// Build graph expression for lookup fields
const lookupFields = objectDefModel.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
}
// Filter fields based on field-level permissions
const filteredRecord = await this.authService.filterReadableFields(record, objectDefModel.fields, user);
return filteredRecord;
}
async createRecord(
@@ -138,17 +595,47 @@ export class ObjectService {
data: any,
userId: string,
) {
if (objectApiName === 'Account') {
return this.prisma.account.create({
data: {
tenantId,
ownerId: userId,
...data,
},
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) {
throw new NotFoundException('User not found');
}
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
// Check if user has create permission
const canCreate = await this.authService.canCreate(objectDefModel, user);
if (!canCreate) {
throw new NotFoundException('You do not have permission to create records of this object');
}
// Filter data to only editable fields
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const recordData = {
...editableData,
ownerId: userId, // Auto-set owner
};
const record = await boundModel.query().insert(recordData);
return record;
}
async updateRecord(
@@ -158,17 +645,54 @@ export class ObjectService {
data: any,
userId: string,
) {
if (objectApiName === 'Account') {
// Verify ownership
await this.getRecord(tenantId, objectApiName, recordId, userId);
return this.prisma.account.update({
where: { id: recordId },
data,
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) {
throw new NotFoundException('User not found');
}
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
const tableName = this.getTableName(objectApiName);
// Get existing record
const existingRecord = await knex(tableName).where({ id: recordId }).first();
if (!existingRecord) {
throw new NotFoundException('Record not found');
}
// Check if user can update this record
await this.authService.assertCanPerformAction('update', objectDefModel, existingRecord, user, knex);
// Filter data to only editable fields
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
// Remove system fields
delete editableData.id;
delete editableData.ownerId;
delete editableData.created_at;
delete editableData.tenantId;
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().where({ id: recordId }).update(editableData);
return boundModel.query().where({ id: recordId }).first();
}
async deleteRecord(
@@ -177,15 +701,202 @@ export class ObjectService {
recordId: string,
userId: string,
) {
if (objectApiName === 'Account') {
// Verify ownership
await this.getRecord(tenantId, objectApiName, recordId, userId);
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) {
throw new NotFoundException('User not found');
}
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
const tableName = this.getTableName(objectApiName);
// Get existing record
const existingRecord = await knex(tableName).where({ id: recordId }).first();
if (!existingRecord) {
throw new NotFoundException('Record not found');
}
// Check if user can delete this record
await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex);
return this.prisma.account.delete({
where: { id: recordId },
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().where({ id: recordId }).delete();
return { success: true };
}
async getFieldPermissions(tenantId: string, objectId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get all field permissions for this object's fields
const permissions = await knex('role_field_permissions as rfp')
.join('field_definitions as fd', 'fd.id', 'rfp.fieldDefinitionId')
.where('fd.objectDefinitionId', objectId)
.select('rfp.*');
return permissions;
}
async updateFieldPermission(
tenantId: string,
roleId: string,
fieldDefinitionId: string,
canRead: boolean,
canEdit: boolean,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Check if permission already exists
const existing = await knex('role_field_permissions')
.where({ roleId, fieldDefinitionId })
.first();
if (existing) {
// Update existing permission
await knex('role_field_permissions')
.where({ roleId, fieldDefinitionId })
.update({
canRead,
canEdit,
updated_at: knex.fn.now(),
});
} else {
// Create new permission
await knex('role_field_permissions').insert({
id: knex.raw('(UUID())'),
roleId,
fieldDefinitionId,
canRead,
canEdit,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
return { success: true };
}
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
async getObjectPermissions(
tenantId: string,
objectApiName: string,
roleId: string,
) {
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 NotFoundException(`Object ${objectApiName} not found`);
}
// Get role object permissions
const permission = await knex('role_object_permissions')
.where({ roleId, objectDefinitionId: objectDef.id })
.first();
if (!permission) {
// Return default permissions (all false)
return {
canCreate: false,
canRead: false,
canEdit: false,
canDelete: false,
canViewAll: false,
canModifyAll: false,
};
}
return {
canCreate: Boolean(permission.canCreate),
canRead: Boolean(permission.canRead),
canEdit: Boolean(permission.canEdit),
canDelete: Boolean(permission.canDelete),
canViewAll: Boolean(permission.canViewAll),
canModifyAll: Boolean(permission.canModifyAll),
};
}
async updateObjectPermissions(
tenantId: string,
objectApiName: string,
data: {
roleId: string;
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canViewAll: boolean;
canModifyAll: boolean;
},
) {
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 NotFoundException(`Object ${objectApiName} not found`);
}
// Check if permission already exists
const existing = await knex('role_object_permissions')
.where({ roleId: data.roleId, objectDefinitionId: objectDef.id })
.first();
if (existing) {
// Update existing permission
await knex('role_object_permissions')
.where({ roleId: data.roleId, objectDefinitionId: objectDef.id })
.update({
canCreate: data.canCreate,
canRead: data.canRead,
canEdit: data.canEdit,
canDelete: data.canDelete,
canViewAll: data.canViewAll,
canModifyAll: data.canModifyAll,
updated_at: knex.fn.now(),
});
} else {
// Create new permission
await knex('role_object_permissions').insert({
id: knex.raw('(UUID())'),
roleId: data.roleId,
objectDefinitionId: objectDef.id,
canCreate: data.canCreate,
canRead: data.canRead,
canEdit: data.canEdit,
canDelete: data.canDelete,
canViewAll: data.canViewAll,
canModifyAll: data.canModifyAll,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
return { success: true };
}
}

View File

@@ -0,0 +1,216 @@
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);
// 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,
) {
const tableName = this.getTableName(objectApiName);
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,
) {
const tableName = this.getTableName(objectApiName);
await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(fieldApiName);
});
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
}
/**
* Drop an object table
*/
async dropObjectTable(knex: Knex, objectApiName: string) {
const tableName = this.getTableName(objectApiName);
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) {
case 'String':
column = table.string(columnName, field.length || 255);
break;
case 'Text':
column = table.text(columnName);
break;
case 'Number':
if (field.scale && field.scale > 0) {
column = table.decimal(
columnName,
field.precision || 10,
field.scale,
);
} else {
column = table.integer(columnName);
}
break;
case 'Boolean':
column = table.boolean(columnName).defaultTo(false);
break;
case 'Date':
column = table.date(columnName);
break;
case 'DateTime':
column = table.datetime(columnName);
break;
case 'Reference':
column = table.uuid(columnName);
if (field.referenceObject) {
const refTableName = this.getTableName(field.referenceObject);
column.references('id').inTable(refTableName).onDelete('SET NULL');
}
break;
case 'Email':
column = table.string(columnName, 255);
break;
case 'Phone':
column = table.string(columnName, 50);
break;
case 'Url':
column = table.string(columnName, 255);
break;
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): string {
// Convert PascalCase to snake_case
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (append 's' if not already plural)
// In production, use a proper pluralization library
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
/**
* 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,26 @@ import {
Controller,
Get,
Post,
Patch,
Put,
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 +33,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 +63,64 @@ 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);
}
@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,54 @@
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;
}>;
};
@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;
}>;
};
@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,198 @@
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;
}
}
}
// Field permissions exist but this field is not explicitly granted → deny
return false;
}
/**
* 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,324 @@
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);
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);
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);
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): 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 + 'es';
} else {
return snakeCase + 's';
}
}
}

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

View File

@@ -0,0 +1,368 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
UnauthorizedException,
Req,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
import { getCentralKnex, initCentralModels } from './central-database.service';
import { TenantProvisioningService } from './tenant-provisioning.service';
import { TenantDatabaseService } from './tenant-database.service';
import * as bcrypt from 'bcrypt';
/**
* Controller for managing central database entities (tenants, domains, users)
* Only accessible when logged in as central admin
*/
@Controller('central')
@UseGuards(JwtAuthGuard)
export class CentralAdminController {
constructor(
private readonly provisioningService: TenantProvisioningService,
private readonly tenantDbService: TenantDatabaseService,
) {
// Initialize central models on controller creation
initCentralModels();
}
private checkCentralAdmin(req: any) {
const subdomain = req.raw?.subdomain;
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
if (!subdomain || !centralSubdomains.includes(subdomain)) {
throw new UnauthorizedException('This endpoint is only accessible to central administrators');
}
}
// ==================== TENANTS ====================
@Get('tenants')
async getTenants(@Req() req: any) {
this.checkCentralAdmin(req);
return CentralTenant.query().withGraphFetched('domains');
}
@Get('tenants/:id')
async getTenant(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
return CentralTenant.query()
.findById(id)
.withGraphFetched('domains');
}
@Post('tenants')
async createTenant(
@Req() req: any,
@Body() data: {
name: string;
slug?: string;
primaryDomain: string;
dbHost?: string;
dbPort?: number;
},
) {
this.checkCentralAdmin(req);
// Use the provisioning service to create tenant with database and migrations
const result = await this.provisioningService.provisionTenant({
name: data.name,
slug: data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
primaryDomain: data.primaryDomain,
dbHost: data.dbHost,
dbPort: data.dbPort,
});
// Return the created tenant
return CentralTenant.query()
.findById(result.tenantId)
.withGraphFetched('domains');
}
@Put('tenants/:id')
async updateTenant(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
name?: string;
slug?: string;
dbHost?: string;
dbPort?: number;
dbName?: string;
dbUsername?: string;
status?: string;
},
) {
this.checkCentralAdmin(req);
return CentralTenant.query()
.patchAndFetchById(id, data);
}
@Delete('tenants/:id')
async deleteTenant(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
await CentralTenant.query().deleteById(id);
return { success: true };
}
// Get users for a specific tenant
@Get('tenants/:id/users')
async getTenantUsers(@Req() req: any, @Param('id') tenantId: string) {
this.checkCentralAdmin(req);
try {
// Get tenant to verify it exists
const tenant = await CentralTenant.query().findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Tenant not found');
}
// Connect to tenant database using tenant ID directly
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
// Fetch users from tenant database
const users = await tenantKnex('users').select('*');
// Remove password from response
return users.map(({ password, ...user }) => user);
} catch (error) {
console.error('Error fetching tenant users:', error);
throw error;
}
}
// Create a user in a specific tenant
@Post('tenants/:id/users')
async createTenantUser(
@Req() req: any,
@Param('id') tenantId: string,
@Body() data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
},
) {
this.checkCentralAdmin(req);
try {
// Get tenant to verify it exists
const tenant = await CentralTenant.query().findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Tenant not found');
}
// Connect to tenant database using tenant ID directly
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
// Hash password
const hashedPassword = await bcrypt.hash(data.password, 10);
// Generate UUID for the new user
const userId = require('crypto').randomUUID();
// Create user in tenant database
await tenantKnex('users').insert({
id: userId,
email: data.email,
password: hashedPassword,
firstName: data.firstName || null,
lastName: data.lastName || null,
created_at: new Date(),
updated_at: new Date(),
});
// Fetch and return the created user
const user = await tenantKnex('users').where('id', userId).first();
if (!user) {
throw new Error('Failed to create user');
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
} catch (error) {
console.error('Error creating tenant user:', error);
throw error;
}
}
// ==================== DOMAINS ====================
@Get('domains')
async getDomains(
@Req() req: any,
@Query('parentId') parentId?: string,
@Query('tenantId') tenantId?: string,
) {
this.checkCentralAdmin(req);
let query = CentralDomain.query().withGraphFetched('tenant');
// Filter by parent/tenant ID if provided (for related lists)
if (parentId || tenantId) {
query = query.where('tenantId', parentId || tenantId);
}
return query;
}
@Get('domains/:id')
async getDomain(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
return CentralDomain.query()
.findById(id)
.withGraphFetched('tenant');
}
@Post('domains')
async createDomain(
@Req() req: any,
@Body() data: {
domain: string;
tenantId: string;
isPrimary?: boolean;
},
) {
this.checkCentralAdmin(req);
return CentralDomain.query().insert({
domain: data.domain,
tenantId: data.tenantId,
isPrimary: data.isPrimary || false,
});
}
@Put('domains/:id')
async updateDomain(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
domain?: string;
tenantId?: string;
isPrimary?: boolean;
},
) {
this.checkCentralAdmin(req);
return CentralDomain.query()
.patchAndFetchById(id, data);
}
@Delete('domains/:id')
async deleteDomain(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
// Get domain info before deleting to invalidate cache
const domain = await CentralDomain.query().findById(id);
// Delete the domain
await CentralDomain.query().deleteById(id);
// Invalidate tenant connection cache for this domain
if (domain) {
this.tenantDbService.removeTenantConnection(domain.domain);
}
return { success: true };
}
// ==================== USERS (Central Admin Users) ====================
@Get('users')
async getUsers(@Req() req: any) {
this.checkCentralAdmin(req);
const users = await CentralUser.query();
// Remove password from response
return users.map(({ password, ...user }) => user);
}
@Get('users/:id')
async getUser(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
const user = await CentralUser.query().findById(id);
if (!user) {
throw new UnauthorizedException('User not found');
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Post('users')
async createUser(
@Req() req: any,
@Body() data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
role?: string;
isActive?: boolean;
},
) {
this.checkCentralAdmin(req);
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await CentralUser.query().insert({
email: data.email,
password: hashedPassword,
firstName: data.firstName || null,
lastName: data.lastName || null,
role: data.role || 'admin',
isActive: data.isActive !== undefined ? data.isActive : true,
});
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Put('users/:id')
async updateUser(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
role?: string;
isActive?: boolean;
},
) {
this.checkCentralAdmin(req);
const updateData: any = { ...data };
// Hash password if provided
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
} else {
// Remove password from update if not provided
delete updateData.password;
}
const user = await CentralUser.query()
.patchAndFetchById(id, updateData);
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Delete('users/:id')
async deleteUser(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
await CentralUser.query().deleteById(id);
return { success: true };
}
}

View File

@@ -0,0 +1,43 @@
import Knex from 'knex';
import { Model } from 'objection';
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
let centralKnex: Knex.Knex | null = null;
/**
* Get or create a Knex instance for the central database
* This is used for Objection models that work with central entities
*/
export function getCentralKnex(): Knex.Knex {
if (!centralKnex) {
const centralDbUrl = process.env.CENTRAL_DATABASE_URL;
if (!centralDbUrl) {
throw new Error('CENTRAL_DATABASE_URL environment variable is not set');
}
centralKnex = Knex({
client: 'mysql2',
connection: centralDbUrl,
pool: {
min: 2,
max: 10,
},
});
// Bind Objection models to this knex instance
Model.knex(centralKnex);
}
return centralKnex;
}
/**
* Initialize central models with the knex instance
*/
export function initCentralModels() {
const knex = getCentralKnex();
CentralTenant.knex(knex);
CentralDomain.knex(knex);
CentralUser.knex(knex);
}

View File

@@ -0,0 +1,267 @@
import { Injectable, Logger } from '@nestjs/common';
import { Knex, knex } from 'knex';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import * as crypto from 'crypto';
@Injectable()
export class TenantDatabaseService {
private readonly logger = new Logger(TenantDatabaseService.name);
private tenantConnections: Map<string, Knex> = new Map();
/**
* Get tenant database connection by domain (for subdomain-based authentication)
* This is used when users log in via tenant subdomains
*/
async getTenantKnexByDomain(domain: string): Promise<Knex> {
const cacheKey = `domain:${domain}`;
// Check if we have a cached connection
if (this.tenantConnections.has(cacheKey)) {
// Validate the domain still exists before returning cached connection
const centralPrisma = getCentralPrisma();
try {
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
});
// If domain no longer exists, remove cached connection
if (!domainRecord) {
this.logger.warn(`Domain ${domain} no longer exists, removing cached connection`);
await this.disconnectTenant(cacheKey);
throw new Error(`Domain ${domain} not found`);
}
} catch (error) {
// If domain doesn't exist, remove from cache and re-throw
if (error.message.includes('not found')) {
throw error;
}
// For other errors, log but continue with cached connection
this.logger.warn(`Error validating domain ${domain}:`, error.message);
}
return this.tenantConnections.get(cacheKey);
}
const centralPrisma = getCentralPrisma();
// Find tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: true },
});
if (!domainRecord) {
throw new Error(`Domain ${domain} not found`);
}
const tenant = domainRecord.tenant;
this.logger.log(`Found tenant by domain: ${domain} -> ${tenant.name}`);
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenant.name} is not active`);
}
// Create connection and cache it
const tenantKnex = await this.createTenantConnection(tenant);
this.tenantConnections.set(cacheKey, tenantKnex);
return tenantKnex;
}
/**
* Get tenant database connection by tenant ID (for central admin operations)
* This is used when central admin needs to access tenant databases
*/
async getTenantKnexById(tenantId: string): Promise<Knex> {
const cacheKey = `id:${tenantId}`;
// Check if we have a cached connection (no validation needed for ID-based lookups)
if (this.tenantConnections.has(cacheKey)) {
return this.tenantConnections.get(cacheKey);
}
const centralPrisma = getCentralPrisma();
// Find tenant by ID
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new Error(`Tenant ${tenantId} not found`);
}
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenant.name} is not active`);
}
this.logger.log(`Connecting to tenant database by ID: ${tenant.name}`);
// Create connection and cache it
const tenantKnex = await this.createTenantConnection(tenant);
this.tenantConnections.set(cacheKey, tenantKnex);
return tenantKnex;
}
/**
* Legacy method - delegates to domain-based lookup
* @deprecated Use getTenantKnexByDomain or getTenantKnexById instead
*/
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
// Assume it's a domain if it contains a dot
return this.getTenantKnexByDomain(tenantIdOrSlug);
}
/**
* Create a new Knex connection to a tenant database
*/
private async createTenantConnection(tenant: any): Promise<Knex> {
// Decrypt password
const decryptedPassword = this.decryptPassword(tenant.dbPassword);
const tenantKnex = knex({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
pool: {
min: 2,
max: 10,
},
});
// Test connection
try {
await tenantKnex.raw('SELECT 1');
this.logger.log(`Connected to tenant database: ${tenant.dbName}`);
} catch (error) {
this.logger.error(
`Failed to connect to tenant database: ${tenant.dbName}`,
error,
);
throw error;
}
return tenantKnex;
}
async getTenantByDomain(domain: string): Promise<any> {
const centralPrisma = getCentralPrisma();
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: true },
});
if (!domainRecord) {
throw new Error(`Domain ${domain} not found`);
}
if (domainRecord.tenant.status !== 'active') {
throw new Error(`Tenant for domain ${domain} is not active`);
}
return domainRecord.tenant;
}
/**
* Resolve tenant by ID or slug
* Tries ID first, then falls back to slug
*/
async resolveTenantId(idOrSlug: string): Promise<string> {
const centralPrisma = getCentralPrisma();
// Try by ID first
let tenant = await centralPrisma.tenant.findUnique({
where: { id: idOrSlug },
});
// If not found, try by slug
if (!tenant) {
tenant = await centralPrisma.tenant.findUnique({
where: { slug: idOrSlug },
});
}
if (!tenant) {
throw new Error(`Tenant ${idOrSlug} not found`);
}
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenant.name} is not active`);
}
return tenant.id;
}
async disconnectTenant(tenantId: string) {
const connection = this.tenantConnections.get(tenantId);
if (connection) {
await connection.destroy();
this.tenantConnections.delete(tenantId);
this.logger.log(`Disconnected tenant: ${tenantId}`);
}
}
removeTenantConnection(tenantId: string) {
this.tenantConnections.delete(tenantId);
this.logger.log(`Removed tenant connection from cache: ${tenantId}`);
}
async disconnectAll() {
for (const [tenantId, connection] of this.tenantConnections.entries()) {
await connection.destroy();
}
this.tenantConnections.clear();
this.logger.log('Disconnected all tenant connections');
}
encryptPassword(password: string): string {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(password, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
private decryptPassword(encryptedPassword: string): string {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Encrypt integrations config JSON object
* @param config - Plain object containing integration credentials
* @returns Encrypted JSON string
*/
encryptIntegrationsConfig(config: any): string {
if (!config) return null;
const jsonString = JSON.stringify(config);
return this.encryptPassword(jsonString);
}
/**
* Decrypt integrations config JSON string
* @param encryptedConfig - Encrypted JSON string
* @returns Plain object with integration credentials
*/
decryptIntegrationsConfig(encryptedConfig: string): any {
if (!encryptedConfig) return null;
const decrypted = this.decryptPassword(encryptedConfig);
return JSON.parse(decrypted);
}
}

View File

@@ -0,0 +1,36 @@
import {
Controller,
Post,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { TenantProvisioningService } from './tenant-provisioning.service';
@Controller('setup/tenants')
export class TenantProvisioningController {
constructor(
private readonly provisioningService: TenantProvisioningService,
) {}
@Post()
async createTenant(
@Body()
data: {
name: string;
slug: string;
primaryDomain: string;
dbHost?: string;
dbPort?: number;
},
) {
return this.provisioningService.provisionTenant(data);
}
@Delete(':tenantId')
async deleteTenant(@Param('tenantId') tenantId: string) {
await this.provisioningService.deprovisionTenant(tenantId);
return { success: true };
}
}

View File

@@ -0,0 +1,344 @@
import { Injectable, Logger } from '@nestjs/common';
import { TenantDatabaseService } from './tenant-database.service';
import * as knex from 'knex';
import * as crypto from 'crypto';
import { getCentralPrisma } from '../prisma/central-prisma.service';
@Injectable()
export class TenantProvisioningService {
private readonly logger = new Logger(TenantProvisioningService.name);
constructor(private readonly tenantDbService: TenantDatabaseService) {}
/**
* Provision a new tenant with database and default data
*/
async provisionTenant(data: {
name: string;
slug: string;
primaryDomain: string;
dbHost?: string;
dbPort?: number;
}) {
const dbHost = data.dbHost || process.env.DB_HOST || 'platform-db';
const dbPort = data.dbPort || parseInt(process.env.DB_PORT || '3306');
const dbName = `tenant_${data.slug}`;
const dbUsername = `tenant_${data.slug}_user`;
const dbPassword = this.generateSecurePassword();
this.logger.log(`Provisioning tenant: ${data.name} (${data.slug})`);
try {
// Step 1: Create MySQL database and user
await this.createTenantDatabase(
dbHost,
dbPort,
dbName,
dbUsername,
dbPassword,
);
// Step 2: Run migrations on new tenant database
await this.runTenantMigrations(
dbHost,
dbPort,
dbName,
dbUsername,
dbPassword,
);
// Step 3: Store tenant info in central database
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.create({
data: {
name: data.name,
slug: data.slug,
dbHost,
dbPort,
dbName,
dbUsername,
dbPassword: this.tenantDbService.encryptPassword(dbPassword),
status: 'active',
domains: {
create: {
domain: data.primaryDomain,
isPrimary: true,
},
},
},
include: {
domains: true,
},
});
this.logger.log(`Tenant provisioned successfully: ${tenant.id}`);
// Step 4: Seed default data (admin user, default roles, etc.)
await this.seedDefaultData(tenant.id);
return {
tenantId: tenant.id,
dbName,
dbUsername,
dbPassword, // Return for initial setup, should be stored securely
};
} catch (error) {
this.logger.error(`Failed to provision tenant: ${data.slug}`, error);
// Attempt cleanup
await this.rollbackProvisioning(dbHost, dbPort, dbName, dbUsername).catch(
(cleanupError) => {
this.logger.error(
'Failed to cleanup after provisioning error',
cleanupError,
);
},
);
throw error;
}
}
/**
* Create MySQL database and user
*/
private async createTenantDatabase(
host: string,
port: number,
dbName: string,
username: string,
password: string,
) {
// Connect as root to create database and user
const rootKnex = knex.default({
client: 'mysql2',
connection: {
host,
port,
user: process.env.DB_ROOT_USER || 'root',
password: process.env.DB_ROOT_PASSWORD || 'root',
},
});
try {
// Create database
await rootKnex.raw(
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
);
this.logger.log(`Database created: ${dbName}`);
// Create user and grant privileges
await rootKnex.raw(
`CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password}'`,
);
await rootKnex.raw(
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%'`,
);
await rootKnex.raw('FLUSH PRIVILEGES');
this.logger.log(`User created: ${username}`);
} finally {
await rootKnex.destroy();
}
}
/**
* Run Knex migrations on tenant database
*/
private async runTenantMigrations(
host: string,
port: number,
dbName: string,
username: string,
password: string,
) {
const tenantKnex = knex.default({
client: 'mysql2',
connection: {
host,
port,
database: dbName,
user: username,
password,
},
migrations: {
directory: './migrations/tenant',
tableName: 'knex_migrations',
},
});
try {
await tenantKnex.migrate.latest();
this.logger.log(`Migrations completed for database: ${dbName}`);
} finally {
await tenantKnex.destroy();
}
}
/**
* Seed default data for new tenant
*/
private async seedDefaultData(tenantId: string) {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
try {
// Create default roles
const adminRoleId = crypto.randomUUID();
await tenantKnex('roles').insert({
id: adminRoleId,
name: 'Admin',
guardName: 'api',
description: 'Full system administrator access',
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
const userRoleId = crypto.randomUUID();
await tenantKnex('roles').insert({
id: userRoleId,
name: 'User',
guardName: 'api',
description: 'Standard user access',
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Create default permissions
const permissions = [
{ name: 'manage_users', description: 'Manage users' },
{ name: 'manage_roles', description: 'Manage roles and permissions' },
{ name: 'manage_apps', description: 'Manage applications' },
{ name: 'manage_objects', description: 'Manage object definitions' },
{ name: 'view_data', description: 'View data' },
{ name: 'create_data', description: 'Create data' },
{ name: 'edit_data', description: 'Edit data' },
{ name: 'delete_data', description: 'Delete data' },
];
for (const perm of permissions) {
await tenantKnex('permissions').insert({
id: crypto.randomUUID(),
name: perm.name,
guardName: 'api',
description: perm.description,
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
}
// Grant all permissions to Admin role
const allPermissions = await tenantKnex('permissions').select('id');
for (const perm of allPermissions) {
await tenantKnex('role_permissions').insert({
id: crypto.randomUUID(),
roleId: adminRoleId,
permissionId: perm.id,
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
}
// Grant view/create/edit permissions to User role
const userPermissions = await tenantKnex('permissions')
.whereIn('name', ['view_data', 'create_data', 'edit_data'])
.select('id');
for (const perm of userPermissions) {
await tenantKnex('role_permissions').insert({
id: crypto.randomUUID(),
roleId: userRoleId,
permissionId: perm.id,
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
}
this.logger.log(`Default data seeded for tenant: ${tenantId}`);
} catch (error) {
this.logger.error(
`Failed to seed default data for tenant: ${tenantId}`,
error,
);
throw error;
}
}
/**
* Rollback provisioning in case of error
*/
private async rollbackProvisioning(
host: string,
port: number,
dbName: string,
username: string,
) {
const rootKnex = knex.default({
client: 'mysql2',
connection: {
host,
port,
user: process.env.DB_ROOT_USER || 'root',
password: process.env.DB_ROOT_PASSWORD || 'root',
},
});
try {
await rootKnex.raw(`DROP DATABASE IF EXISTS \`${dbName}\``);
await rootKnex.raw(`DROP USER IF EXISTS '${username}'@'%'`);
this.logger.log(`Rolled back provisioning for database: ${dbName}`);
} finally {
await rootKnex.destroy();
}
}
/**
* Generate secure random password
*/
private generateSecurePassword(): string {
return crypto.randomBytes(32).toString('base64').slice(0, 32);
}
/**
* Deprovision a tenant (delete database and central record)
*/
async deprovisionTenant(tenantId: string) {
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new Error(`Tenant not found: ${tenantId}`);
}
try {
// Delete tenant database
const rootKnex = knex.default({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: process.env.DB_ROOT_USER || 'root',
password: process.env.DB_ROOT_PASSWORD || 'root',
},
});
try {
await rootKnex.raw(`DROP DATABASE IF EXISTS \`${tenant.dbName}\``);
await rootKnex.raw(`DROP USER IF EXISTS '${tenant.dbUsername}'@'%'`);
this.logger.log(`Database deleted: ${tenant.dbName}`);
} finally {
await rootKnex.destroy();
}
// Delete tenant from central database
await centralPrisma.tenant.delete({
where: { id: tenantId },
});
// Remove from connection cache
this.tenantDbService.removeTenantConnection(tenantId);
this.logger.log(`Tenant deprovisioned: ${tenantId}`);
} catch (error) {
this.logger.error(`Failed to deprovision tenant: ${tenantId}`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,102 @@
import {
Controller,
Get,
Put,
Body,
UseGuards,
Req,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantDatabaseService } from './tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import { TenantId } from './tenant.decorator';
@Controller('tenant')
@UseGuards(JwtAuthGuard)
export class TenantController {
constructor(private readonly tenantDbService: TenantDatabaseService) {}
/**
* Get integrations configuration for the current tenant
*/
@Get('integrations')
async getIntegrationsConfig(@TenantId() tenantId: string) {
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
});
if (!tenant || !tenant.integrationsConfig) {
return { data: null };
}
// Decrypt the config
const config = this.tenantDbService.decryptIntegrationsConfig(
tenant.integrationsConfig as any,
);
// Return config with sensitive fields masked
const maskedConfig = this.maskSensitiveFields(config);
return { data: maskedConfig };
}
/**
* Update integrations configuration for the current tenant
*/
@Put('integrations')
async updateIntegrationsConfig(
@TenantId() tenantId: string,
@Body() body: { integrationsConfig: any },
) {
const { integrationsConfig } = body;
// Encrypt the config
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
integrationsConfig,
);
// Update in database
const centralPrisma = getCentralPrisma();
await centralPrisma.tenant.update({
where: { id: tenantId },
data: {
integrationsConfig: encryptedConfig as any,
},
});
return {
success: true,
message: 'Integrations configuration updated successfully',
};
}
/**
* Mask sensitive fields for API responses
*/
private maskSensitiveFields(config: any): any {
if (!config) return null;
const masked = { ...config };
// Mask Twilio credentials
if (masked.twilio) {
masked.twilio = {
...masked.twilio,
authToken: masked.twilio.authToken ? '••••••••' : '',
};
}
// Mask OpenAI credentials
if (masked.openai) {
masked.openai = {
...masked.openai,
apiKey: masked.openai.apiKey ? '••••••••' : '',
};
}
return masked;
}
}

View File

@@ -1,16 +1,134 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { TenantDatabaseService } from './tenant-database.service';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const tenantId = req.headers['x-tenant-id'] as string;
if (tenantId) {
// Attach tenantId to request object
(req as any).tenantId = tenantId;
private readonly logger = new Logger(TenantMiddleware.name);
constructor(private readonly tenantDbService: TenantDatabaseService) {}
async use(
req: FastifyRequest['raw'],
res: FastifyReply['raw'],
next: () => void,
) {
try {
// Extract subdomain from hostname
const host = req.headers.host || '';
const hostname = host.split(':')[0]; // Remove port if present
// Check Origin header to get frontend subdomain (for API calls)
const origin = req.headers.origin as string;
const referer = req.headers.referer as string;
let parts = hostname.split('.');
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`);
// For local development, accept x-tenant-id header
let tenantId = req.headers['x-tenant-id'] as string;
let subdomain: string | null = null;
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
// Try to extract subdomain from Origin header first (for API calls from frontend)
if (origin) {
try {
const originUrl = new URL(origin);
const originHost = originUrl.hostname;
parts = originHost.split('.');
this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`);
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
} else if (referer && !tenantId) {
// Fallback to Referer if no Origin
try {
const refererUrl = new URL(referer);
const refererHost = refererUrl.hostname;
parts = refererHost.split('.');
this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`);
} catch (error) {
this.logger.warn(`Failed to parse referer: ${referer}`);
}
}
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
// For production domains with 3+ parts, extract first part as subdomain
if (parts.length >= 3) {
subdomain = parts[0];
// Ignore www subdomain
if (subdomain === 'www') {
subdomain = null;
}
}
// For development (e.g., tenant1.localhost), also check 2 parts
else if (parts.length === 2 && parts[1] === 'localhost') {
subdomain = parts[0];
}
this.logger.log(`Extracted subdomain: ${subdomain}`);
// Always attach subdomain to request if present
if (subdomain) {
(req as any).subdomain = subdomain;
}
// If x-tenant-id is explicitly provided, use it directly but still keep subdomain
if (tenantId) {
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
(req as any).tenantId = tenantId;
next();
return;
}
// Always attach subdomain to request if present
if (subdomain) {
(req as any).subdomain = subdomain;
}
// Check if this is a central subdomain
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
const isCentral = subdomain && centralSubdomains.includes(subdomain);
// If it's a central subdomain, skip tenant resolution
if (isCentral) {
this.logger.log(`Central subdomain detected: ${subdomain}, skipping tenant resolution`);
(req as any).subdomain = subdomain;
next();
return;
}
// Get tenant by subdomain if available
if (subdomain) {
try {
const tenant = await this.tenantDbService.getTenantByDomain(subdomain);
if (tenant) {
tenantId = tenant.id;
this.logger.log(
`Tenant identified: ${tenant.name} (${tenant.id}) from subdomain: ${subdomain}`,
);
}
} catch (error) {
this.logger.warn(`No tenant found for subdomain: ${subdomain}`, error.message);
// Fall back to using subdomain as tenantId directly if domain lookup fails
tenantId = subdomain;
this.logger.log(`Using subdomain as tenantId fallback: ${tenantId}`);
}
}
if (tenantId) {
// Attach tenant info to request object
(req as any).tenantId = tenantId;
} else {
this.logger.warn(`No tenant identified from host: ${hostname}`);
}
next();
} catch (error) {
this.logger.error('Error in tenant middleware', error);
next();
}
next();
}
}

View File

@@ -1,7 +1,22 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { TenantMiddleware } from './tenant.middleware';
import { TenantDatabaseService } from './tenant-database.service';
import { TenantProvisioningService } from './tenant-provisioning.service';
import { TenantProvisioningController } from './tenant-provisioning.controller';
import { CentralAdminController } from './central-admin.controller';
import { TenantController } from './tenant.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({})
@Module({
imports: [PrismaModule],
controllers: [TenantProvisioningController, CentralAdminController, TenantController],
providers: [
TenantDatabaseService,
TenantProvisioningService,
TenantMiddleware,
],
exports: [TenantDatabaseService, TenantProvisioningService],
})
export class TenantModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
export interface TwilioConfig {
accountSid: string;
authToken: string;
phoneNumber: string;
apiKeySid?: string;
apiKeySecret?: string;
}
export interface OpenAIConfig {
apiKey: string;
assistantId?: string;
model?: string;
voice?: string;
}
export interface IntegrationsConfig {
twilio?: TwilioConfig;
openai?: OpenAIConfig;
}

View File

@@ -0,0 +1,195 @@
import {
Controller,
Post,
Get,
Body,
Req,
Res,
UseGuards,
Logger,
Query,
} from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { VoiceService } from './voice.service';
import { VoiceGateway } from './voice.gateway';
import { InitiateCallDto } from './dto/initiate-call.dto';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('voice')
export class VoiceController {
private readonly logger = new Logger(VoiceController.name);
constructor(
private readonly voiceService: VoiceService,
private readonly voiceGateway: VoiceGateway,
) {}
/**
* Initiate outbound call via REST
*/
@Post('call')
@UseGuards(JwtAuthGuard)
async initiateCall(
@Body() body: InitiateCallDto,
@Req() req: any,
@TenantId() tenantId: string,
) {
const userId = req.user?.userId || req.user?.sub;
const result = await this.voiceService.initiateCall({
tenantId,
userId,
toNumber: body.toNumber,
});
return {
success: true,
data: result,
};
}
/**
* Get call history
*/
@Get('calls')
@UseGuards(JwtAuthGuard)
async getCallHistory(
@Req() req: any,
@TenantId() tenantId: string,
@Query('limit') limit?: string,
) {
const userId = req.user?.userId || req.user?.sub;
const calls = await this.voiceService.getCallHistory(
tenantId,
userId,
limit ? parseInt(limit) : 50,
);
return {
success: true,
data: calls,
};
}
/**
* TwiML for outbound calls
*/
@Post('twiml/outbound')
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Start>
<Stream url="wss://${req.headers.host}/api/voice/stream" />
</Start>
<Say>Connecting your call</Say>
<Dial>
<Number>${(req.body as any).To}</Number>
</Dial>
</Response>`;
res.type('text/xml').send(twiml);
}
/**
* TwiML for inbound calls
*/
@Post('twiml/inbound')
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
const body = req.body as any;
const callSid = body.CallSid;
const fromNumber = body.From;
const toNumber = body.To;
this.logger.log(`Incoming call: ${callSid} from ${fromNumber} to ${toNumber}`);
// TODO: Determine tenant from phone number mapping
// TODO: Find available user to route call to
// For now, return a simple TwiML response
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Start>
<Stream url="wss://${req.headers.host}/api/voice/stream" />
</Start>
<Say>Please wait while we connect you to an agent</Say>
<Dial>
<Queue>support</Queue>
</Dial>
</Response>`;
res.type('text/xml').send(twiml);
}
/**
* Twilio status webhook
*/
@Post('webhook/status')
async statusWebhook(@Req() req: FastifyRequest) {
const body = req.body as any;
const callSid = body.CallSid;
const status = body.CallStatus;
const duration = body.CallDuration ? parseInt(body.CallDuration) : undefined;
this.logger.log(`Call status update: ${callSid} -> ${status}`);
// TODO: Extract tenant ID from call record
// For now, we'll need to lookup the call to get tenant ID
// This is a limitation - we should store tenantId in call metadata
try {
// Update call status
// await this.voiceService.updateCallStatus({
// callSid,
// tenantId: 'LOOKUP_NEEDED',
// status,
// duration,
// });
// Notify user via WebSocket
// await this.voiceGateway.notifyCallUpdate(userId, {
// callSid,
// status,
// duration,
// });
} catch (error) {
this.logger.error('Failed to process status webhook', error);
}
return { success: true };
}
/**
* Twilio recording webhook
*/
@Post('webhook/recording')
async recordingWebhook(@Req() req: FastifyRequest) {
const body = req.body as any;
const callSid = body.CallSid;
const recordingUrl = body.RecordingUrl;
this.logger.log(`Recording available for call ${callSid}: ${recordingUrl}`);
// TODO: Update call record with recording URL
// TODO: Trigger transcription if needed
return { success: true };
}
/**
* WebSocket endpoint for Twilio Media Streams
*/
@Post('stream')
async mediaStream(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
// Twilio Media Streams use WebSocket protocol
// This would need to be handled by the WebSocket server
// In Fastify, we need to upgrade the connection
this.logger.log('Media stream connection requested');
// TODO: Implement WebSocket upgrade for media streams
// This will handle bidirectional audio streaming between Twilio and OpenAI
res.send({ message: 'WebSocket upgrade required' });
}
}

View File

@@ -0,0 +1,273 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { VoiceService } from './voice.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
interface AuthenticatedSocket extends Socket {
tenantId?: string;
userId?: string;
tenantSlug?: string;
}
@WebSocketGateway({
namespace: 'voice',
cors: {
origin: true,
credentials: true,
},
})
export class VoiceGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(VoiceGateway.name);
private connectedUsers: Map<string, AuthenticatedSocket> = new Map();
private activeCallsByUser: Map<string, string> = new Map(); // userId -> callSid
constructor(
private readonly jwtService: JwtService,
private readonly voiceService: VoiceService,
private readonly tenantDbService: TenantDatabaseService,
) {}
async handleConnection(client: AuthenticatedSocket) {
try {
// Extract token from handshake auth
const token =
client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
if (!token) {
this.logger.warn('Client connection rejected: No token provided');
client.disconnect();
return;
}
// Verify JWT token
const payload = await this.jwtService.verifyAsync(token);
client.tenantId = payload.tenantId;
client.userId = payload.sub;
client.tenantSlug = payload.tenantSlug;
this.connectedUsers.set(client.userId, client);
this.logger.log(
`Client connected: ${client.id} (User: ${client.userId}, Tenant: ${client.tenantSlug})`,
);
// Send current call state if any active call
const activeCallSid = this.activeCallsByUser.get(client.userId);
if (activeCallSid) {
const callState = await this.voiceService.getCallState(
activeCallSid,
client.tenantId,
);
client.emit('call:state', callState);
}
} catch (error) {
this.logger.error('Authentication failed', error);
client.disconnect();
}
}
handleDisconnect(client: AuthenticatedSocket) {
if (client.userId) {
this.connectedUsers.delete(client.userId);
this.logger.log(`Client disconnected: ${client.id} (User: ${client.userId})`);
}
}
/**
* Initiate outbound call
*/
@SubscribeMessage('call:initiate')
async handleInitiateCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { toNumber: string },
) {
try {
this.logger.log(`Initiating call from user ${client.userId} to ${data.toNumber}`);
const result = await this.voiceService.initiateCall({
tenantId: client.tenantId,
userId: client.userId,
toNumber: data.toNumber,
});
this.activeCallsByUser.set(client.userId, result.callSid);
client.emit('call:initiated', {
callSid: result.callSid,
toNumber: data.toNumber,
status: 'queued',
});
return { success: true, callSid: result.callSid };
} catch (error) {
this.logger.error('Failed to initiate call', error);
client.emit('call:error', {
message: error.message || 'Failed to initiate call',
});
return { success: false, error: error.message };
}
}
/**
* Accept incoming call
*/
@SubscribeMessage('call:accept')
async handleAcceptCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string },
) {
try {
this.logger.log(`User ${client.userId} accepting call ${data.callSid}`);
await this.voiceService.acceptCall({
callSid: data.callSid,
tenantId: client.tenantId,
userId: client.userId,
});
this.activeCallsByUser.set(client.userId, data.callSid);
client.emit('call:accepted', { callSid: data.callSid });
return { success: true };
} catch (error) {
this.logger.error('Failed to accept call', error);
return { success: false, error: error.message };
}
}
/**
* Reject incoming call
*/
@SubscribeMessage('call:reject')
async handleRejectCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string },
) {
try {
this.logger.log(`User ${client.userId} rejecting call ${data.callSid}`);
await this.voiceService.rejectCall(data.callSid, client.tenantId);
client.emit('call:rejected', { callSid: data.callSid });
return { success: true };
} catch (error) {
this.logger.error('Failed to reject call', error);
return { success: false, error: error.message };
}
}
/**
* End active call
*/
@SubscribeMessage('call:end')
async handleEndCall(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string },
) {
try {
this.logger.log(`User ${client.userId} ending call ${data.callSid}`);
await this.voiceService.endCall(data.callSid, client.tenantId);
this.activeCallsByUser.delete(client.userId);
client.emit('call:ended', { callSid: data.callSid });
return { success: true };
} catch (error) {
this.logger.error('Failed to end call', error);
return { success: false, error: error.message };
}
}
/**
* Send DTMF tones
*/
@SubscribeMessage('call:dtmf')
async handleDtmf(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { callSid: string; digit: string },
) {
try {
await this.voiceService.sendDtmf(
data.callSid,
data.digit,
client.tenantId,
);
return { success: true };
} catch (error) {
this.logger.error('Failed to send DTMF', error);
return { success: false, error: error.message };
}
}
/**
* Emit incoming call notification to specific user
*/
async notifyIncomingCall(userId: string, callData: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('call:incoming', callData);
this.logger.log(`Notified user ${userId} of incoming call`);
} else {
this.logger.warn(`User ${userId} not connected to receive call notification`);
}
}
/**
* Emit call status update to user
*/
async notifyCallUpdate(userId: string, callData: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('call:update', callData);
}
}
/**
* Emit AI transcript to user
*/
async notifyAiTranscript(userId: string, data: { callSid: string; transcript: string; isFinal: boolean }) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('ai:transcript', data);
}
}
/**
* Emit AI suggestion to user
*/
async notifyAiSuggestion(userId: string, data: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('ai:suggestion', data);
}
}
/**
* Emit AI action result to user
*/
async notifyAiAction(userId: string, data: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit('ai:action', data);
}
}
}

View File

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

View File

@@ -0,0 +1,575 @@
import { Injectable, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import { IntegrationsConfig, TwilioConfig, OpenAIConfig } from './interfaces/integration-config.interface';
import * as Twilio from 'twilio';
import { WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class VoiceService {
private readonly logger = new Logger(VoiceService.name);
private twilioClients: Map<string, Twilio.Twilio> = new Map();
private openaiConnections: Map<string, WebSocket> = new Map(); // callSid -> WebSocket
private callStates: Map<string, any> = new Map(); // callSid -> call state
constructor(
private readonly tenantDbService: TenantDatabaseService,
) {}
/**
* Get Twilio client for a tenant
*/
private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig }> {
// Check cache first
if (this.twilioClients.has(tenantId)) {
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
});
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
return { client: this.twilioClients.get(tenantId), config: config.twilio };
}
// Fetch tenant integrations config
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
});
if (!tenant?.integrationsConfig) {
throw new Error('Tenant integrations config not found');
}
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
if (!config.twilio?.accountSid || !config.twilio?.authToken) {
throw new Error('Twilio credentials not configured for tenant');
}
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
this.twilioClients.set(tenantId, client);
return { client, config: config.twilio };
}
/**
* Decrypt and parse integrations config
*/
private getIntegrationConfig(encryptedConfig: any): IntegrationsConfig {
if (!encryptedConfig) {
return {};
}
// If it's already decrypted (object), return it
if (typeof encryptedConfig === 'object' && encryptedConfig.twilio) {
return encryptedConfig;
}
// If it's encrypted (string), decrypt it
if (typeof encryptedConfig === 'string') {
return this.tenantDbService.decryptIntegrationsConfig(encryptedConfig);
}
return {};
}
/**
* Initiate outbound call
*/
async initiateCall(params: {
tenantId: string;
userId: string;
toNumber: string;
}) {
const { tenantId, userId, toNumber } = params;
try {
const { client, config } = await this.getTwilioClient(tenantId);
// Create call record in database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const callId = uuidv4();
// Generate TwiML URL for call flow
const twimlUrl = `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/twiml/outbound`;
// Initiate call via Twilio
const call = await client.calls.create({
to: toNumber,
from: config.phoneNumber,
url: twimlUrl,
statusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/status`,
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
statusCallbackMethod: 'POST',
record: true,
recordingStatusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/recording`,
});
// Store call in database
await tenantKnex('calls').insert({
id: callId,
call_sid: call.sid,
direction: 'outbound',
from_number: config.phoneNumber,
to_number: toNumber,
status: 'queued',
user_id: userId,
created_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Store call state in memory
this.callStates.set(call.sid, {
callId,
callSid: call.sid,
tenantId,
userId,
direction: 'outbound',
status: 'queued',
});
this.logger.log(`Outbound call initiated: ${call.sid}`);
return {
callId,
callSid: call.sid,
status: 'queued',
};
} catch (error) {
this.logger.error('Failed to initiate call', error);
throw error;
}
}
/**
* Accept incoming call
*/
async acceptCall(params: {
callSid: string;
tenantId: string;
userId: string;
}) {
const { callSid, tenantId, userId } = params;
try {
// Note: Twilio doesn't support updating call to 'in-progress' via API
// Call status is managed by TwiML and call flow
// We'll update our database status instead
// Update database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
status: 'in-progress',
user_id: userId,
started_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Update state
const state = this.callStates.get(callSid) || {};
this.callStates.set(callSid, {
...state,
status: 'in-progress',
userId,
});
this.logger.log(`Call accepted: ${callSid} by user ${userId}`);
} catch (error) {
this.logger.error('Failed to accept call', error);
throw error;
}
}
/**
* Reject incoming call
*/
async rejectCall(callSid: string, tenantId: string) {
try {
const { client } = await this.getTwilioClient(tenantId);
// End the call
await client.calls(callSid).update({
status: 'completed',
});
// Update database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
status: 'canceled',
updated_at: tenantKnex.fn.now(),
});
// Clean up state
this.callStates.delete(callSid);
this.logger.log(`Call rejected: ${callSid}`);
} catch (error) {
this.logger.error('Failed to reject call', error);
throw error;
}
}
/**
* End active call
*/
async endCall(callSid: string, tenantId: string) {
try {
const { client } = await this.getTwilioClient(tenantId);
// End the call
await client.calls(callSid).update({
status: 'completed',
});
// Clean up OpenAI connection if exists
const openaiWs = this.openaiConnections.get(callSid);
if (openaiWs) {
openaiWs.close();
this.openaiConnections.delete(callSid);
}
// Update database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
status: 'completed',
ended_at: tenantKnex.fn.now(),
updated_at: tenantKnex.fn.now(),
});
// Clean up state
this.callStates.delete(callSid);
this.logger.log(`Call ended: ${callSid}`);
} catch (error) {
this.logger.error('Failed to end call', error);
throw error;
}
}
/**
* Send DTMF tones
*/
async sendDtmf(callSid: string, digit: string, tenantId: string) {
try {
const { client } = await this.getTwilioClient(tenantId);
// Twilio doesn't support sending DTMF directly via API
// This would need to be handled via TwiML <Play> of DTMF tones
this.logger.log(`DTMF requested for call ${callSid}: ${digit}`);
// TODO: Implement DTMF sending via TwiML update
} catch (error) {
this.logger.error('Failed to send DTMF', error);
throw error;
}
}
/**
* Get call state
*/
async getCallState(callSid: string, tenantId: string) {
// Try memory first
if (this.callStates.has(callSid)) {
return this.callStates.get(callSid);
}
// Fallback to database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const call = await tenantKnex('calls')
.where({ call_sid: callSid })
.first();
return call || null;
}
/**
* Update call status from webhook
*/
async updateCallStatus(params: {
callSid: string;
tenantId: string;
status: string;
duration?: number;
recordingUrl?: string;
}) {
const { callSid, tenantId, status, duration, recordingUrl } = params;
try {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const updateData: any = {
status,
updated_at: tenantKnex.fn.now(),
};
if (duration !== undefined) {
updateData.duration_seconds = duration;
}
if (recordingUrl) {
updateData.recording_url = recordingUrl;
}
if (status === 'completed') {
updateData.ended_at = tenantKnex.fn.now();
}
await tenantKnex('calls')
.where({ call_sid: callSid })
.update(updateData);
// Update state
const state = this.callStates.get(callSid);
if (state) {
this.callStates.set(callSid, { ...state, status });
}
this.logger.log(`Call status updated: ${callSid} -> ${status}`);
} catch (error) {
this.logger.error('Failed to update call status', error);
throw error;
}
}
/**
* Initialize OpenAI Realtime connection for call
*/
async initializeOpenAIRealtime(params: {
callSid: string;
tenantId: string;
userId: string;
}) {
const { callSid, tenantId, userId } = params;
try {
// Get OpenAI config
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
});
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
if (!config.openai?.apiKey) {
this.logger.warn('OpenAI not configured for tenant, skipping AI features');
return;
}
// Connect to OpenAI Realtime API
const ws = new WebSocket('wss://api.openai.com/v1/realtime', {
headers: {
'Authorization': `Bearer ${config.openai.apiKey}`,
'OpenAI-Beta': 'realtime=v1',
},
});
ws.on('open', () => {
this.logger.log(`OpenAI Realtime connected for call ${callSid}`);
// Initialize session
ws.send(JSON.stringify({
type: 'session.update',
session: {
model: config.openai.model || 'gpt-4o-realtime-preview',
voice: config.openai.voice || 'alloy',
instructions: 'You are a helpful AI assistant providing real-time support during phone calls. Provide concise, actionable suggestions to help the user.',
turn_detection: {
type: 'server_vad',
},
tools: this.getOpenAITools(),
},
}));
});
ws.on('message', (data: Buffer) => {
this.handleOpenAIMessage(callSid, tenantId, userId, JSON.parse(data.toString()));
});
ws.on('error', (error) => {
this.logger.error(`OpenAI WebSocket error for call ${callSid}`, error);
});
ws.on('close', () => {
this.logger.log(`OpenAI Realtime disconnected for call ${callSid}`);
this.openaiConnections.delete(callSid);
});
this.openaiConnections.set(callSid, ws);
} catch (error) {
this.logger.error('Failed to initialize OpenAI Realtime', error);
}
}
/**
* Handle OpenAI Realtime messages
*/
private async handleOpenAIMessage(
callSid: string,
tenantId: string,
userId: string,
message: any,
) {
try {
switch (message.type) {
case 'conversation.item.created':
if (message.item.type === 'message' && message.item.role === 'assistant') {
// AI response generated
this.logger.log(`AI response for call ${callSid}`);
}
break;
case 'response.audio_transcript.delta':
// Real-time transcript
// TODO: Emit to gateway
break;
case 'response.audio_transcript.done':
// Final transcript
const transcript = message.transcript;
await this.updateCallTranscript(callSid, tenantId, transcript);
break;
case 'response.function_call_arguments.done':
// Tool call completed
await this.handleToolCall(callSid, tenantId, userId, message);
break;
default:
// Handle other message types
break;
}
} catch (error) {
this.logger.error('Failed to handle OpenAI message', error);
}
}
/**
* Define OpenAI tools for CRM actions
*/
private getOpenAITools(): any[] {
return [
{
type: 'function',
name: 'search_contact',
description: 'Search for a contact by name, email, or phone number',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (name, email, or phone)',
},
},
required: ['query'],
},
},
{
type: 'function',
name: 'create_task',
description: 'Create a follow-up task based on the call',
parameters: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Task title',
},
description: {
type: 'string',
description: 'Task description',
},
dueDate: {
type: 'string',
description: 'Due date (ISO format)',
},
},
required: ['title'],
},
},
{
type: 'function',
name: 'update_contact',
description: 'Update contact information',
parameters: {
type: 'object',
properties: {
contactId: {
type: 'string',
description: 'Contact ID',
},
fields: {
type: 'object',
description: 'Fields to update',
},
},
required: ['contactId', 'fields'],
},
},
];
}
/**
* Handle tool calls from OpenAI
*/
private async handleToolCall(
callSid: string,
tenantId: string,
userId: string,
message: any,
) {
// TODO: Implement actual tool execution
// This would call the appropriate services based on the tool name
// Respecting RBAC permissions for the user
this.logger.log(`Tool call for call ${callSid}: ${message.name}`);
}
/**
* Update call transcript
*/
private async updateCallTranscript(
callSid: string,
tenantId: string,
transcript: string,
) {
try {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
await tenantKnex('calls')
.where({ call_sid: callSid })
.update({
ai_transcript: transcript,
updated_at: tenantKnex.fn.now(),
});
} catch (error) {
this.logger.error('Failed to update transcript', error);
}
}
/**
* Get call history for user
*/
async getCallHistory(tenantId: string, userId: string, limit = 50) {
try {
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const calls = await tenantKnex('calls')
.where({ user_id: userId })
.orderBy('created_at', 'desc')
.limit(limit);
return calls;
} catch (error) {
this.logger.error('Failed to get call history', error);
throw error;
}
}
}

View File

@@ -0,0 +1,231 @@
# Central Admin Authentication Guide
## Overview
The platform now supports **two types of authentication**:
1. **Tenant Login** - Authenticates users against a specific tenant's database
2. **Central Admin Login** - Authenticates administrators against the central platform database
## Central vs Tenant Authentication
### Tenant Authentication (Default)
- Users login to their specific tenant database
- Each tenant has isolated user tables
- Access is scoped to the tenant's data
- API Endpoint: `/api/auth/login`
- Requires `x-tenant-id` header or subdomain detection
### Central Admin Authentication
- Administrators login to the central platform database
- Can manage all tenants and platform-wide features
- Users stored in the central database `users` table
- API Endpoint: `/api/central/auth/login`
- No tenant ID required
## Creating a Central Admin User
### Quick Start
```bash
cd backend
npm run create-central-admin
```
Follow the interactive prompts to create your admin user.
### Environment Variable Method
```bash
EMAIL=admin@platform.com \
PASSWORD=SecureP@ssw0rd \
FIRST_NAME=Admin \
LAST_NAME=User \
ROLE=superadmin \
npm run create-central-admin
```
### Role Types
- **admin** - Standard administrator with platform management access
- **superadmin** - Super administrator with full platform access
## Logging In as Central Admin
### Frontend Login
1. Navigate to the login page (`/login`)
2. **Check the "Login as Central Admin" checkbox**
3. Enter your central admin email and password
4. Click "Login to Central"
The checkbox toggles between:
-**Checked** - Authenticates against central database
-**Unchecked** - Authenticates against tenant database (default)
### API Login (Direct)
**Central Admin Login:**
```bash
curl -X POST http://localhost:3000/api/central/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@platform.com",
"password": "SecureP@ssw0rd"
}'
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "cm5a1b2c3d4e5f6g7h8i9j0k",
"email": "admin@platform.com",
"firstName": "Admin",
"lastName": "User",
"role": "superadmin",
"isCentralAdmin": true
}
}
```
**Tenant Login (for comparison):**
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-H "x-tenant-id: tenant1" \
-d '{
"email": "user@tenant1.com",
"password": "password123"
}'
```
## JWT Token Differences
### Central Admin Token Payload
```json
{
"sub": "user-id",
"email": "admin@platform.com",
"isCentralAdmin": true,
"iat": 1234567890,
"exp": 1234654290
}
```
### Tenant User Token Payload
```json
{
"sub": "user-id",
"email": "user@tenant1.com",
"iat": 1234567890,
"exp": 1234654290
}
```
The `isCentralAdmin` flag in the JWT can be used to determine if the user is a central admin.
## Database Schema
### Central Database - `users` Table
```sql
CREATE TABLE users (
id VARCHAR(30) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
firstName VARCHAR(100),
lastName VARCHAR(100),
role VARCHAR(50) DEFAULT 'admin',
isActive BOOLEAN DEFAULT true,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### Tenant Database - `users` Table
Tenant databases have their own separate `users` table with similar structure but tenant-specific users.
## Security Considerations
1. **Separate Password Storage** - Central admin passwords are stored separately from tenant user passwords
2. **Role-Based Access** - Central admins have different permissions than tenant users
3. **JWT Identification** - The `isCentralAdmin` flag helps identify admin users
4. **Encryption** - All passwords are hashed using bcrypt with salt rounds
## Common Use Cases
### Platform Administration
- **Login as:** Central Admin
- **Can do:**
- Create/manage tenants
- View all tenant information
- Manage platform-wide settings
- Access tenant provisioning APIs
### Tenant Management
- **Login as:** Tenant User
- **Can do:**
- Access tenant-specific data
- Manage records within the tenant
- Use tenant applications
- Limited to tenant scope
## Troubleshooting
### "Tenant ID is required" Error
- You're trying to login to tenant endpoint without tenant ID
- Solution: Either provide `x-tenant-id` header or use central admin login
### "Invalid credentials" with Central Login
- Check that you're using the "Login as Central Admin" checkbox
- Verify the user exists in the central database
- Use the script to create a central admin if needed
### "User already exists"
- A central admin with that email already exists
- Use a different email or reset the existing user's password
## Architecture
```
┌─────────────────────────────────────────┐
│ Frontend Login Form │
│ ┌────────────────────────────────────┐ │
│ │ ☑ Login as Central Admin │ │
│ └────────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
┌───────┴────────┐
│ Checked? │
└───────┬────────┘
┌──────────┴──────────┐
│ │
▼ ▼
/api/central/auth/login /api/auth/login
│ │
▼ ▼
Central Database Tenant Database
(users table) (users table)
```
## API Endpoints Summary
| Endpoint | Purpose | Requires Tenant ID | Database |
|----------|---------|-------------------|----------|
| `POST /api/central/auth/login` | Central admin login | ❌ No | Central |
| `POST /api/central/auth/register` | Create central admin | ❌ No | Central |
| `POST /api/auth/login` | Tenant user login | ✅ Yes | Tenant |
| `POST /api/auth/register` | Create tenant user | ✅ Yes | Tenant |
## Next Steps
1. Create your first central admin user
2. Login with the central admin checkbox enabled
3. Access platform administration features
4. Manage tenants and platform settings
For tenant management and provisioning, see [TENANT_MIGRATION_GUIDE.md](../TENANT_MIGRATION_GUIDE.md).

130
docs/CENTRAL_LOGIN.md Normal file
View File

@@ -0,0 +1,130 @@
# Central Admin Login
## Overview
The platform supports seamless authentication for both **tenant users** and **central administrators** using the same login endpoint. The system automatically determines which database to authenticate against based on the subdomain.
## How It Works
### Subdomain-Based Routing
The authentication flow uses subdomain detection to determine the authentication context:
1. **Central Subdomains** (e.g., `central.yourdomain.com`, `admin.yourdomain.com`)
- Authenticates against the **central database**
- Used for platform administrators
- Configured via `CENTRAL_SUBDOMAINS` environment variable
2. **Tenant Subdomains** (e.g., `acme.yourdomain.com`, `client1.yourdomain.com`)
- Authenticates against the **tenant's database**
- Used for regular tenant users
- Each tenant has its own isolated database
### Configuration
Set the central subdomains in your `.env` file:
```bash
# Comma-separated list of subdomains that access the central database
CENTRAL_SUBDOMAINS="central,admin"
```
### Implementation Details
#### 1. Tenant Middleware (`tenant.middleware.ts`)
The middleware extracts the subdomain from the request and:
- Checks if it matches a central subdomain
- If yes: Skips tenant resolution and attaches subdomain to request
- If no: Resolves the tenant ID from the subdomain and attaches it to request
#### 2. Auth Service (`auth.service.ts`)
The auth service has branching logic in `validateUser()` and `register()`:
- Checks if the subdomain is in the central list
- Routes to `validateCentralUser()` or normal tenant user validation
- Central users are authenticated against the `central` database
- Tenant users are authenticated against their tenant's database
#### 3. Auth Controller (`auth.controller.ts`)
The controller:
- Extracts subdomain from the request
- Validates tenant ID requirement (not needed for central subdomains)
- Passes subdomain to auth service for proper routing
## Usage
### Creating a Central Admin User
```bash
cd backend
npm run create-central-admin
```
Follow the prompts to enter:
- Email
- Password
- First Name (optional)
- Last Name (optional)
### Logging In as Central Admin
1. Navigate to `central.yourdomain.com` (or whatever central subdomain you configured)
2. Enter your central admin email and password
3. You'll be authenticated against the central database
**No special UI elements needed** - the system automatically detects the subdomain!
### Logging In as Tenant User
1. Navigate to `yourtenantslug.yourdomain.com`
2. Enter your tenant user credentials
3. You'll be authenticated against that tenant's database
## Architecture Benefits
**Transparent to Frontend** - No need for special "login as admin" checkboxes or UI elements
**Secure** - Central and tenant authentication are completely separated
**Scalable** - Easy to add more central subdomains by updating environment variable
**Clean Code** - Single auth controller/service with clear branching logic
**Flexible** - Can be used for both development (localhost) and production
## Local Development
For local development, you can:
1. **Use subdomain on localhost:**
```
central.localhost:3000
acme.localhost:3000
```
2. **Use x-tenant-id header** (for tenant-specific requests):
```bash
curl -H "x-tenant-id: acme-corp" http://localhost:3000/api/auth/login
```
3. **For central admin, use central subdomain:**
```bash
curl http://central.localhost:3000/api/auth/login
```
## Database Schema
### Central Database (`User` model)
- Stores platform administrators
- Prisma schema: `schema-central.prisma`
- Fields: id, email, password, firstName, lastName, isActive, createdAt, updatedAt
### Tenant Database (`users` table)
- Stores tenant-specific users
- Knex migrations: `migrations/tenant/`
- Fields: id, email, password, firstName, lastName, isActive, created_at, updated_at
## Security Considerations
- Central admin credentials are never stored in tenant databases
- Tenant user credentials are never stored in the central database
- JWT tokens include user context (tenant ID or central admin flag)
- Subdomain validation prevents unauthorized access

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