Added auth functionality, initial work with views and field types
This commit is contained in:
20
backend/.env.example
Normal file
20
backend/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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"
|
||||
91
backend/MIGRATION_QUICK_REFERENCE.txt
Normal file
91
backend/MIGRATION_QUICK_REFERENCE.txt
Normal 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
19
backend/knexfile.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
35
backend/migrations/tenant/20250126000003_create_apps.js
Normal file
35
backend/migrations/tenant/20250126000003_create_apps.js
Normal 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');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
341
backend/package-lock.json
generated
341
backend/package-lock.json
generated
@@ -22,6 +22,9 @@
|
||||
"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",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
@@ -3341,6 +3344,15 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -4016,6 +4028,12 @@
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
|
||||
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -4167,6 +4185,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/db-errors": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/db-errors/-/db-errors-0.2.3.tgz",
|
||||
"integrity": "sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4473,7 +4497,6 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -4684,6 +4707,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/esm": {
|
||||
"version": "3.2.25",
|
||||
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
||||
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
@@ -5317,7 +5349,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -5350,6 +5381,15 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -5399,7 +5439,6 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
@@ -5432,6 +5471,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/getopts": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
|
||||
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
@@ -5640,7 +5685,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -5813,6 +5857,15 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
|
||||
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
||||
@@ -5870,7 +5923,6 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@@ -5954,6 +6006,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -6983,6 +7041,98 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/knex": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz",
|
||||
"integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "2.0.19",
|
||||
"commander": "^10.0.0",
|
||||
"debug": "4.3.4",
|
||||
"escalade": "^3.1.1",
|
||||
"esm": "^3.2.25",
|
||||
"get-package-type": "^0.1.0",
|
||||
"getopts": "2.3.0",
|
||||
"interpret": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"pg-connection-string": "2.6.2",
|
||||
"rechoir": "^0.8.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"tarn": "^3.0.2",
|
||||
"tildify": "2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"knex": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"better-sqlite3": {
|
||||
"optional": true
|
||||
},
|
||||
"mysql": {
|
||||
"optional": true
|
||||
},
|
||||
"mysql2": {
|
||||
"optional": true
|
||||
},
|
||||
"pg": {
|
||||
"optional": true
|
||||
},
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
},
|
||||
"sqlite3": {
|
||||
"optional": true
|
||||
},
|
||||
"tedious": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/knex/node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/knex/node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/knex/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/knex/node_modules/resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -7168,6 +7318,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -7178,6 +7334,21 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
|
||||
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
@@ -7473,6 +7644,63 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"long": "^5.2.1",
|
||||
"lru.min": "^1.0.0",
|
||||
"named-placeholders": "^1.1.3",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
||||
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru-cache": "^7.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders/node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -7618,6 +7846,55 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/objection": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/objection/-/objection-3.1.5.tgz",
|
||||
"integrity": "sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"db-errors": "^0.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"knex": ">=1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/objection/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/objection/node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
@@ -7860,7 +8137,6 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
@@ -7908,6 +8184,12 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
|
||||
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -8309,6 +8591,18 @@
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve": "^1.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
@@ -8369,7 +8663,6 @@
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
@@ -8619,7 +8912,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
@@ -8693,6 +8985,11 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
@@ -8842,6 +9139,15 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
@@ -9015,7 +9321,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -9096,6 +9401,15 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tarn": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
|
||||
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.44.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||
@@ -9292,6 +9606,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tildify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
|
||||
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
|
||||
@@ -17,24 +17,33 @@
|
||||
"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": {
|
||||
"@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",
|
||||
"@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",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -42,11 +51,11 @@
|
||||
"@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",
|
||||
|
||||
@@ -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;
|
||||
238
backend/prisma/migrations/20251129033827_init/migration.sql
Normal file
238
backend/prisma/migrations/20251129033827_init/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
54
backend/prisma/schema-central.prisma
Normal file
54
backend/prisma/schema-central.prisma
Normal file
@@ -0,0 +1,54 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../node_modules/.prisma/central"
|
||||
}
|
||||
|
||||
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
|
||||
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")
|
||||
}
|
||||
@@ -1,39 +1,21 @@
|
||||
// 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"
|
||||
output = "../node_modules/.prisma/tenant"
|
||||
}
|
||||
|
||||
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 +23,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 +92,59 @@ 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)
|
||||
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 +152,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 +160,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")
|
||||
}
|
||||
|
||||
|
||||
194
backend/scripts/README.md
Normal file
194
backend/scripts/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Tenant Migration Scripts
|
||||
|
||||
This directory contains scripts for managing database migrations across all tenants in the multi-tenant platform.
|
||||
|
||||
## Available 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
|
||||
```
|
||||
181
backend/scripts/check-migration-status.ts
Normal file
181
backend/scripts/check-migration-status.ts
Normal 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);
|
||||
});
|
||||
50
backend/scripts/create-admin-user.ts
Normal file
50
backend/scripts/create-admin-user.ts
Normal 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();
|
||||
138
backend/scripts/create-tenant-user.ts
Normal file
138
backend/scripts/create-tenant-user.ts
Normal 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();
|
||||
165
backend/scripts/migrate-all-tenants.ts
Normal file
165
backend/scripts/migrate-all-tenants.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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);
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.message);
|
||||
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);
|
||||
});
|
||||
134
backend/scripts/migrate-tenant.ts
Normal file
134
backend/scripts/migrate-tenant.ts
Normal 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);
|
||||
});
|
||||
147
backend/seeds/example_account_fields_with_ui_metadata.js
Normal file
147
backend/seeds/example_account_fields_with_ui_metadata.js
Normal 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');
|
||||
};
|
||||
349
backend/seeds/example_contact_fields_with_ui_metadata.js
Normal file
349
backend/seeds/example_contact_fields_with_ui_metadata.js
Normal 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!');
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,12 @@ export class AuthController {
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private tenantDbService: TenantDatabaseService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@@ -15,34 +15,29 @@ export class AuthService {
|
||||
email: string,
|
||||
password: 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const user = await tenantDb('users')
|
||||
.where({ email })
|
||||
.first();
|
||||
|
||||
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)) {
|
||||
// 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;
|
||||
@@ -52,7 +47,6 @@ export class AuthService {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -62,7 +56,6 @@ export class AuthService {
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
tenantId: user.tenantId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,18 +67,24 @@ export class AuthService {
|
||||
firstName?: string,
|
||||
lastName?: string,
|
||||
) {
|
||||
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
23
backend/src/models/account.model.ts
Normal file
23
backend/src/models/account.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
25
backend/src/models/app-page.model.ts
Normal file
25
backend/src/models/app-page.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
23
backend/src/models/app.model.ts
Normal file
23
backend/src/models/app.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
18
backend/src/models/base.model.ts
Normal file
18
backend/src/models/base.model.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
78
backend/src/models/field-definition.model.ts
Normal file
78
backend/src/models/field-definition.model.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
46
backend/src/models/object-definition.model.ts
Normal file
46
backend/src/models/object-definition.model.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
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' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { FieldDefinition } = require('./field-definition.model');
|
||||
|
||||
return {
|
||||
fields: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: FieldDefinition,
|
||||
join: {
|
||||
from: 'object_definitions.id',
|
||||
to: 'field_definitions.objectDefinitionId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
25
backend/src/models/permission.model.ts
Normal file
25
backend/src/models/permission.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
28
backend/src/models/role-permission.model.ts
Normal file
28
backend/src/models/role-permission.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
66
backend/src/models/role.model.ts
Normal file
66
backend/src/models/role.model.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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');
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
28
backend/src/models/user-role.model.ts
Normal file
28
backend/src/models/user-role.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
57
backend/src/models/user.model.ts
Normal file
57
backend/src/models/user.model.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
295
backend/src/object/field-mapper.service.ts
Normal file
295
backend/src/object/field-mapper.service.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
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 {
|
||||
const uiMetadata = field.uiMetadata || {};
|
||||
|
||||
return {
|
||||
id: field.id,
|
||||
apiName: field.apiName,
|
||||
label: field.label,
|
||||
type: this.mapFieldType(field.type),
|
||||
|
||||
// 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,
|
||||
relationDisplayField: 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ 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';
|
||||
|
||||
@Module({
|
||||
providers: [ObjectService],
|
||||
imports: [TenantModule],
|
||||
providers: [ObjectService, SchemaManagementService, FieldMapperService],
|
||||
controllers: [RuntimeObjectController, SetupObjectController],
|
||||
exports: [ObjectService],
|
||||
exports: [ObjectService, SchemaManagementService, FieldMapperService],
|
||||
})
|
||||
export class ObjectModule {}
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
// Setup endpoints - Object metadata management
|
||||
async getObjectDefinitions(tenantId: string) {
|
||||
return this.prisma.objectDefinition.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
return knex('object_definitions')
|
||||
.select('*')
|
||||
.orderBy('label', 'asc');
|
||||
}
|
||||
|
||||
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 knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
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');
|
||||
|
||||
return {
|
||||
...obj,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
async createObjectDefinition(
|
||||
@@ -49,13 +45,15 @@ export class ObjectService {
|
||||
isSystem?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.objectDefinition.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...data,
|
||||
tableName: `custom_${data.apiName.toLowerCase()}`,
|
||||
},
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const [id] = await knex('object_definitions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('object_definitions').where({ id }).first();
|
||||
}
|
||||
|
||||
async createFieldDefinition(
|
||||
@@ -68,20 +66,22 @@ export class ObjectService {
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
isUnique?: boolean;
|
||||
isLookup?: boolean;
|
||||
referenceTo?: string;
|
||||
referenceObject?: string;
|
||||
defaultValue?: string;
|
||||
options?: any;
|
||||
},
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const obj = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
|
||||
return this.prisma.fieldDefinition.create({
|
||||
data: {
|
||||
objectId: obj.id,
|
||||
...data,
|
||||
},
|
||||
const [id] = await knex('field_definitions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
objectDefinitionId: obj.id,
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('field_definitions').where({ id }).first();
|
||||
}
|
||||
|
||||
// Runtime endpoints - CRUD operations
|
||||
@@ -91,19 +91,16 @@ export class ObjectService {
|
||||
userId: string,
|
||||
filters?: any,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
// For demonstration, using Account as example static object
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
ownerId: userId, // Basic sharing rule
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return knex('accounts')
|
||||
.where({ ownerId: userId })
|
||||
.where(filters || {});
|
||||
}
|
||||
|
||||
// For custom objects, you'd need dynamic query building
|
||||
// This is a simplified version
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
|
||||
@@ -113,14 +110,12 @@ export class ObjectService {
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
const record = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
id: recordId,
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
const record = await knex('accounts')
|
||||
.where({ id: recordId, ownerId: userId })
|
||||
.first();
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
@@ -138,14 +133,18 @@ export class ObjectService {
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
...data,
|
||||
},
|
||||
const [id] = await knex('accounts').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
ownerId: userId,
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('accounts').where({ id }).first();
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
@@ -158,14 +157,17 @@ export class ObjectService {
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.update({
|
||||
where: { id: recordId },
|
||||
data,
|
||||
});
|
||||
await knex('accounts')
|
||||
.where({ id: recordId })
|
||||
.update({ ...data, updated_at: knex.fn.now() });
|
||||
|
||||
return knex('accounts').where({ id: recordId }).first();
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
@@ -177,13 +179,15 @@ export class ObjectService {
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.delete({
|
||||
where: { id: recordId },
|
||||
});
|
||||
await knex('accounts').where({ id: recordId }).delete();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
|
||||
216
backend/src/object/schema-management.service.ts
Normal file
216
backend/src/object/schema-management.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
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';
|
||||
|
||||
@Controller('setup/objects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SetupObjectController {
|
||||
constructor(private objectService: ObjectService) {}
|
||||
constructor(
|
||||
private objectService: ObjectService,
|
||||
private fieldMapperService: FieldMapperService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getObjectDefinitions(@TenantId() tenantId: string) {
|
||||
@@ -28,6 +32,18 @@ export class SetupObjectController {
|
||||
return this.objectService.getObjectDefinition(tenantId, objectApiName);
|
||||
}
|
||||
|
||||
@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()
|
||||
async createObjectDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
|
||||
16
backend/src/prisma/central-prisma.service.ts
Normal file
16
backend/src/prisma/central-prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
132
backend/src/tenant/tenant-database.service.ts
Normal file
132
backend/src/tenant/tenant-database.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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();
|
||||
|
||||
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
|
||||
if (this.tenantConnections.has(tenantIdOrSlug)) {
|
||||
return this.tenantConnections.get(tenantIdOrSlug);
|
||||
}
|
||||
|
||||
const centralPrisma = getCentralPrisma();
|
||||
|
||||
// Try to find tenant by ID first, then by slug
|
||||
let tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantIdOrSlug },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { slug: tenantIdOrSlug },
|
||||
});
|
||||
}
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant ${tenantIdOrSlug} not found`);
|
||||
}
|
||||
|
||||
if (tenant.status !== 'active') {
|
||||
throw new Error(`Tenant ${tenantIdOrSlug} is not active`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.tenantConnections.set(tenantIdOrSlug, tenantKnex);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
36
backend/src/tenant/tenant-provisioning.controller.ts
Normal file
36
backend/src/tenant/tenant-provisioning.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
344
backend/src/tenant/tenant-provisioning.service.ts
Normal file
344
backend/src/tenant/tenant-provisioning.service.ts
Normal 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.getTenantKnex(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,88 @@
|
||||
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
|
||||
const parts = hostname.split('.');
|
||||
|
||||
this.logger.log(`Host header: ${host}, hostname: ${hostname}, 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}`);
|
||||
|
||||
// If x-tenant-id is explicitly provided, use it directly
|
||||
if (tenantId) {
|
||||
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
|
||||
(req as any).tenantId = tenantId;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
|
||||
// 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;
|
||||
if (subdomain) {
|
||||
(req as any).subdomain = subdomain;
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
this.logger.error('Error in tenant middleware', error);
|
||||
next();
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
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 { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [TenantProvisioningController],
|
||||
providers: [
|
||||
TenantDatabaseService,
|
||||
TenantProvisioningService,
|
||||
TenantMiddleware,
|
||||
],
|
||||
exports: [TenantDatabaseService, TenantProvisioningService],
|
||||
})
|
||||
export class TenantModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||
|
||||
Reference in New Issue
Block a user