From 57f27d28cd66384475bff9f69ded97b53ac35a3f Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sat, 29 Nov 2025 05:09:00 +0100 Subject: [PATCH] WIP - multitenant initial work --- MULTI_TENANT_IMPLEMENTATION.md | 315 ++++++++++++++++ MULTI_TENANT_MIGRATION.md | 115 ++++++ backend/.env.example | 20 + backend/knexfile.js | 19 + .../20250126000001_create_users_and_rbac.js | 78 ++++ ...0250126000002_create_object_definitions.js | 48 +++ .../tenant/20250126000003_create_apps.js | 35 ++ .../20250126000004_create_standard_objects.js | 111 ++++++ backend/package-lock.json | 341 ++++++++++++++++- backend/package.json | 19 +- .../migration.sql | 116 ++++++ .../20251129033827_init/migration.sql | 238 ++++++++++++ backend/prisma/migrations/migration_lock.toml | 2 +- backend/prisma/schema-central.prisma | 40 ++ backend/prisma/schema.prisma | 7 +- backend/src/models/account.model.ts | 23 ++ backend/src/models/app-page.model.ts | 24 ++ backend/src/models/app.model.ts | 22 ++ backend/src/models/base.model.ts | 16 + backend/src/models/field-definition.model.ts | 33 ++ backend/src/models/object-definition.model.ts | 46 +++ backend/src/models/permission.model.ts | 25 ++ backend/src/models/role-permission.model.ts | 28 ++ backend/src/models/role.model.ts | 66 ++++ backend/src/models/user-role.model.ts | 28 ++ backend/src/models/user.model.ts | 57 +++ backend/src/object/object.module.ts | 7 +- .../src/object/schema-management.service.ts | 216 +++++++++++ backend/src/prisma/central-prisma.service.ts | 16 + backend/src/tenant/tenant-database.service.ts | 124 +++++++ .../tenant/tenant-provisioning.controller.ts | 36 ++ .../src/tenant/tenant-provisioning.service.ts | 342 ++++++++++++++++++ backend/src/tenant/tenant.middleware.ts | 66 +++- backend/src/tenant/tenant.module.ts | 15 +- infra/docker-compose.yml | 6 +- test-multi-tenant.sh | 120 ++++++ 36 files changed, 2784 insertions(+), 36 deletions(-) create mode 100644 MULTI_TENANT_IMPLEMENTATION.md create mode 100644 MULTI_TENANT_MIGRATION.md create mode 100644 backend/.env.example create mode 100644 backend/knexfile.js create mode 100644 backend/migrations/tenant/20250126000001_create_users_and_rbac.js create mode 100644 backend/migrations/tenant/20250126000002_create_object_definitions.js create mode 100644 backend/migrations/tenant/20250126000003_create_apps.js create mode 100644 backend/migrations/tenant/20250126000004_create_standard_objects.js create mode 100644 backend/prisma/migrations/20251126221924_init_central_db/migration.sql create mode 100644 backend/prisma/migrations/20251129033827_init/migration.sql create mode 100644 backend/prisma/schema-central.prisma create mode 100644 backend/src/models/account.model.ts create mode 100644 backend/src/models/app-page.model.ts create mode 100644 backend/src/models/app.model.ts create mode 100644 backend/src/models/base.model.ts create mode 100644 backend/src/models/field-definition.model.ts create mode 100644 backend/src/models/object-definition.model.ts create mode 100644 backend/src/models/permission.model.ts create mode 100644 backend/src/models/role-permission.model.ts create mode 100644 backend/src/models/role.model.ts create mode 100644 backend/src/models/user-role.model.ts create mode 100644 backend/src/models/user.model.ts create mode 100644 backend/src/object/schema-management.service.ts create mode 100644 backend/src/prisma/central-prisma.service.ts create mode 100644 backend/src/tenant/tenant-database.service.ts create mode 100644 backend/src/tenant/tenant-provisioning.controller.ts create mode 100644 backend/src/tenant/tenant-provisioning.service.ts create mode 100755 test-multi-tenant.sh diff --git a/MULTI_TENANT_IMPLEMENTATION.md b/MULTI_TENANT_IMPLEMENTATION.md new file mode 100644 index 0000000..0bad17a --- /dev/null +++ b/MULTI_TENANT_IMPLEMENTATION.md @@ -0,0 +1,315 @@ +# Multi-Tenant Migration - Implementation Summary + +## Overview + +The platform has been migrated from a single-database multi-tenant architecture to a **one database per tenant** architecture with subdomain-based tenant identification. + +## Architecture Changes + +### Database Layer + +- **Central Database** (Prisma): Stores tenant metadata, domain mappings, encrypted credentials +- **Tenant Databases** (Knex.js + Objection.js): One MySQL database per tenant with isolated data + +### Tenant Identification + +- **Before**: `x-tenant-id` header +- **After**: Subdomain extraction from hostname (e.g., `acme.routebox.co` → tenant `acme`) +- **Fallback**: `x-tenant-id` header for local development + +### Technology Stack + +- **Central DB ORM**: Prisma 5.8.0 +- **Tenant DB Migration**: Knex.js 3.x +- **Tenant DB ORM**: Objection.js 3.x +- **Database Driver**: mysql2 + +## File Structure + +### Backend - Tenant Management + +``` +src/tenant/ +├── tenant-database.service.ts # Knex connection manager with encryption +├── tenant-provisioning.service.ts # Create/destroy tenant databases +├── tenant-provisioning.controller.ts # API for tenant provisioning +├── tenant.middleware.ts # Subdomain extraction & tenant injection +└── tenant.module.ts # Module configuration + +migrations/tenant/ # Knex migrations for tenant databases +├── 20250126000001_create_users_and_rbac.js +├── 20250126000002_create_object_definitions.js +├── 20250126000003_create_apps.js +└── 20250126000004_create_standard_objects.js +``` + +### Backend - Models (Objection.js) + +``` +src/models/ +├── base.model.ts # Base model with timestamps +├── user.model.ts # User with roles +├── role.model.ts # Role with permissions +├── permission.model.ts # Permission +├── user-role.model.ts # User-Role join table +├── role-permission.model.ts # Role-Permission join table +├── object-definition.model.ts # Dynamic object metadata +├── field-definition.model.ts # Field metadata +├── app.model.ts # Application +├── app-page.model.ts # Application pages +└── account.model.ts # Standard Account object +``` + +### Backend - Schema Management + +``` +src/object/ +├── schema-management.service.ts # Dynamic table creation from ObjectDefinitions +└── object.service.ts # Object CRUD operations (needs migration) +``` + +### Central Database Schema (Prisma) + +``` +prisma/ +├── schema-central.prisma # Tenant, Domain models +└── migrations/ # Will be created when generating +``` + +## Setup Instructions + +### 1. Environment Configuration + +Copy `.env.example` to `.env` and configure: + +```bash +cd /root/neo/backend +cp .env.example .env +``` + +Generate encryption key: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Update `.env` with the generated key and database URLs: + +```env +CENTRAL_DATABASE_URL="mysql://user:password@platform-db:3306/central_platform" +ENCRYPTION_KEY="" +DB_ROOT_USER="root" +DB_ROOT_PASSWORD="root" +``` + +### 2. Central Database Setup + +Generate Prisma client and run migrations: + +```bash +cd /root/neo/backend +npx prisma generate --schema=./prisma/schema-central.prisma +npx prisma migrate dev --schema=./prisma/schema-central.prisma --name init +``` + +### 3. Tenant Provisioning + +Create a new tenant via API: + +```bash +curl -X POST http://localhost:3000/setup/tenants \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Acme Corporation", + "slug": "acme", + "primaryDomain": "acme" + }' +``` + +This will: + +1. Create MySQL database `tenant_acme` +2. Create database user `tenant_acme_user` +3. Run all Knex migrations on the new database +4. Seed default roles and permissions +5. Store encrypted credentials in central database +6. Create domain mapping (`acme` → tenant) + +### 4. Testing Subdomain Routing + +Update your hosts file or DNS to point subdomains to your server: + +``` +127.0.0.1 acme.localhost +127.0.0.1 demo.localhost +``` + +Access the application: + +- Central setup: `http://localhost:3000/setup/tenants` +- Tenant app: `http://acme.localhost:3000/` +- Different tenant: `http://demo.localhost:3000/` + +## Migration Status + +### ✅ Completed + +- [x] Central database schema (Tenant, Domain models) +- [x] Knex + Objection.js installation +- [x] TenantDatabaseService with dynamic connections +- [x] Password encryption/decryption (AES-256-CBC) +- [x] Base Objection.js models (User, Role, Permission, etc.) +- [x] Knex migrations for base tenant schema +- [x] Tenant middleware with subdomain extraction +- [x] Tenant provisioning service (create/destroy) +- [x] Schema management service (dynamic table creation) + +### 🔄 Pending + +- [ ] Generate Prisma client for central database +- [ ] Run Prisma migrations for central database +- [ ] Migrate AuthService from Prisma to Objection.js +- [ ] Migrate RBACService from Prisma to Objection.js +- [ ] Migrate ObjectService from Prisma to Objection.js +- [ ] Migrate AppBuilderService from Prisma to Objection.js +- [ ] Update frontend to work with subdomains +- [ ] Test tenant provisioning flow +- [ ] Test subdomain routing +- [ ] Test database isolation + +## Service Migration Guide + +### Example: Migrating a Service from Prisma to Objection + +**Before (Prisma):** + +```typescript +async findUser(email: string) { + return this.prisma.user.findUnique({ where: { email } }); +} +``` + +**After (Objection + Knex):** + +```typescript +constructor(private readonly tenantDbService: TenantDatabaseService) {} + +async findUser(tenantId: string, email: string) { + const knex = await this.tenantDbService.getTenantKnex(tenantId); + return User.query(knex).findOne({ email }); +} +``` + +### Key Changes + +1. Inject `TenantDatabaseService` instead of `PrismaService` +2. Get tenant Knex connection: `await this.tenantDbService.getTenantKnex(tenantId)` +3. Use Objection models: `User.query(knex).findOne({ email })` +4. Pass `tenantId` to all service methods (extract from request in controller) + +## API Changes + +### Tenant Provisioning Endpoints + +**Create Tenant** + +``` +POST /setup/tenants +Content-Type: application/json + +{ + "name": "Company Name", + "slug": "company-slug", + "primaryDomain": "company", + "dbHost": "platform-db", // optional + "dbPort": 3306 // optional +} + +Response: +{ + "tenantId": "uuid", + "dbName": "tenant_company-slug", + "dbUsername": "tenant_company-slug_user", + "dbPassword": "generated-password" +} +``` + +**Delete Tenant** + +``` +DELETE /setup/tenants/:tenantId + +Response: +{ + "success": true +} +``` + +## Security Considerations + +1. **Encryption**: Tenant database passwords are encrypted with AES-256-CBC before storage +2. **Isolation**: Each tenant has a dedicated MySQL database and user +3. **Credentials**: Database credentials stored in central DB, never exposed to tenants +4. **Subdomain Validation**: Middleware validates tenant exists and is active before processing requests + +## Troubleshooting + +### Connection Issues + +Check tenant connection cache: + +```typescript +await this.tenantDbService.disconnectTenant(tenantId); +const knex = await this.tenantDbService.getTenantKnex(tenantId); // Fresh connection +``` + +### Migration Issues + +Run migrations manually: + +```bash +cd /root/neo/backend +npx knex migrate:latest --knexfile=knexfile.js +``` + +### Encryption Key Issues + +If `ENCRYPTION_KEY` is not set, generate one: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +## Next Steps + +1. **Generate Central DB Schema** + + ```bash + npx prisma generate --schema=./prisma/schema-central.prisma + npx prisma migrate dev --schema=./prisma/schema-central.prisma + ``` + +2. **Migrate Existing Services** + + - Start with `AuthService` (most critical) + - Then `RBACService`, `ObjectService`, `AppBuilderService` + - Update all controllers to extract `tenantId` from request + +3. **Frontend Updates** + + - Update API calls to include subdomain + - Test cross-tenant isolation + - Update login flow to redirect to tenant subdomain + +4. **Testing** + + - Create multiple test tenants + - Verify data isolation + - Test subdomain routing + - Performance testing with multiple connections + +5. **Production Deployment** + - Set up wildcard DNS for subdomains + - Configure SSL certificates for subdomains + - Set up database backup strategy per tenant + - Monitor connection pool usage diff --git a/MULTI_TENANT_MIGRATION.md b/MULTI_TENANT_MIGRATION.md new file mode 100644 index 0000000..02d953c --- /dev/null +++ b/MULTI_TENANT_MIGRATION.md @@ -0,0 +1,115 @@ +# Multi-Tenant Migration Guide + +## Overview + +This guide walks you through migrating existing services from the single-database architecture to the new multi-database per-tenant architecture. + +## Architecture Comparison + +### Before (Single Database) + +```typescript +// Single Prisma client, data segregated by tenantId column +@Injectable() +export class UserService { + constructor(private prisma: PrismaService) {} + + async findUserByEmail(tenantId: string, email: string) { + return this.prisma.user.findFirst({ + where: { tenantId, email }, + }); + } +} +``` + +### After (Multi-Database) + +```typescript +// Dynamic Knex connection per tenant, complete database isolation +@Injectable() +export class UserService { + constructor(private tenantDb: TenantDatabaseService) {} + + async findUserByEmail(tenantId: string, email: string) { + const knex = await this.tenantDb.getTenantKnex(tenantId); + return User.query(knex).findOne({ email }); + } +} +``` + +## Step-by-Step Service Migration Examples + +See full examples in the file for: + +- AuthService migration +- RBACService migration +- ObjectService migration +- Controller updates +- Common query patterns +- Testing strategies + +## Quick Reference + +### Query Patterns + +**Simple Query** + +```typescript +// Prisma +const user = await this.prisma.user.findUnique({ where: { tenantId, id } }); + +// Objection +const knex = await this.tenantDb.getTenantKnex(tenantId); +const user = await User.query(knex).findById(id); +``` + +**Query with Relations** + +```typescript +// Prisma +const user = await this.prisma.user.findUnique({ + where: { tenantId, id }, + include: { roles: { include: { permissions: true } } }, +}); + +// Objection +const user = await User.query(knex) + .findById(id) + .withGraphFetched("roles.permissions"); +``` + +**Create** + +```typescript +// Prisma +const user = await this.prisma.user.create({ data: { ... } }); + +// Objection +const user = await User.query(knex).insert({ ... }); +``` + +**Update** + +```typescript +// Prisma +const user = await this.prisma.user.update({ where: { id }, data: { ... } }); + +// Objection +const user = await User.query(knex).patchAndFetchById(id, { ... }); +``` + +**Delete** + +```typescript +// Prisma +await this.prisma.user.delete({ where: { id } }); + +// Objection +await User.query(knex).deleteById(id); +``` + +## Resources + +- [Knex.js Documentation](https://knexjs.org) +- [Objection.js Documentation](https://vincit.github.io/objection.js) +- [MULTI_TENANT_IMPLEMENTATION.md](./MULTI_TENANT_IMPLEMENTATION.md) - Full implementation details diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..caaefd8 --- /dev/null +++ b/backend/.env.example @@ -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" diff --git a/backend/knexfile.js b/backend/knexfile.js new file mode 100644 index 0000000..4cc7e9c --- /dev/null +++ b/backend/knexfile.js @@ -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', + }, + }, +}; diff --git a/backend/migrations/tenant/20250126000001_create_users_and_rbac.js b/backend/migrations/tenant/20250126000001_create_users_and_rbac.js new file mode 100644 index 0000000..c9a88f4 --- /dev/null +++ b/backend/migrations/tenant/20250126000001_create_users_and_rbac.js @@ -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'); +}; diff --git a/backend/migrations/tenant/20250126000002_create_object_definitions.js b/backend/migrations/tenant/20250126000002_create_object_definitions.js new file mode 100644 index 0000000..a6ef700 --- /dev/null +++ b/backend/migrations/tenant/20250126000002_create_object_definitions.js @@ -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'); +}; diff --git a/backend/migrations/tenant/20250126000003_create_apps.js b/backend/migrations/tenant/20250126000003_create_apps.js new file mode 100644 index 0000000..8a0ab79 --- /dev/null +++ b/backend/migrations/tenant/20250126000003_create_apps.js @@ -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('displayOrder').defaultTo(0); + table.timestamps(true, true); + + table.index(['slug']); + }) + .createTable('app_pages', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); + table.uuid('appId').notNullable(); + table.string('slug', 255).notNullable(); + table.string('label', 255).notNullable(); + table.string('type', 50).notNullable(); // List, Detail, Custom + table.string('objectApiName', 255); + table.integer('displayOrder').defaultTo(0); + table.timestamps(true, true); + + table + .foreign('appId') + .references('id') + .inTable('apps') + .onDelete('CASCADE'); + table.unique(['appId', 'slug']); + table.index(['appId']); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('app_pages').dropTableIfExists('apps'); +}; diff --git a/backend/migrations/tenant/20250126000004_create_standard_objects.js b/backend/migrations/tenant/20250126000004_create_standard_objects.js new file mode 100644 index 0000000..0d65594 --- /dev/null +++ b/backend/migrations/tenant/20250126000004_create_standard_objects.js @@ -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'); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 5ac3bec..8bd1bb0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 24dba11..bd0042e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,21 +20,24 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "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 +45,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", diff --git a/backend/prisma/migrations/20251126221924_init_central_db/migration.sql b/backend/prisma/migrations/20251126221924_init_central_db/migration.sql new file mode 100644 index 0000000..eda7b2e --- /dev/null +++ b/backend/prisma/migrations/20251126221924_init_central_db/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20251129033827_init/migration.sql b/backend/prisma/migrations/20251129033827_init/migration.sql new file mode 100644 index 0000000..e4aff3a --- /dev/null +++ b/backend/prisma/migrations/20251129033827_init/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index 9bee74d..e5a788a 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/backend/prisma/schema-central.prisma b/backend/prisma/schema-central.prisma new file mode 100644 index 0000000..5d30e66 --- /dev/null +++ b/backend/prisma/schema-central.prisma @@ -0,0 +1,40 @@ +generator client { + provider = "prisma-client-js" + output = "../node_modules/.prisma/central" +} + +datasource db { + provider = "mysql" + url = env("CENTRAL_DATABASE_URL") +} + +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") +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a2c85e5..dd60ff2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,13 +1,14 @@ -// 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 generator client { provider = "prisma-client-js" + output = "../node_modules/.prisma/tenant" } datasource db { provider = "mysql" - url = env("DATABASE_URL") + url = env("TENANT_DATABASE_URL") } // Multi-tenancy diff --git a/backend/src/models/account.model.ts b/backend/src/models/account.model.ts new file mode 100644 index 0000000..0081e0e --- /dev/null +++ b/backend/src/models/account.model.ts @@ -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', + }, + }, + }; +} diff --git a/backend/src/models/app-page.model.ts b/backend/src/models/app-page.model.ts new file mode 100644 index 0000000..49fbaf2 --- /dev/null +++ b/backend/src/models/app-page.model.ts @@ -0,0 +1,24 @@ +import { BaseModel } from './base.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.model', + join: { + from: 'app_pages.appId', + to: 'apps.id', + }, + }, + }; +} diff --git a/backend/src/models/app.model.ts b/backend/src/models/app.model.ts new file mode 100644 index 0000000..b88b935 --- /dev/null +++ b/backend/src/models/app.model.ts @@ -0,0 +1,22 @@ +import { BaseModel } from './base.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: 'app-page.model', + join: { + from: 'apps.id', + to: 'app_pages.appId', + }, + }, + }; +} diff --git a/backend/src/models/base.model.ts b/backend/src/models/base.model.ts new file mode 100644 index 0000000..3be990e --- /dev/null +++ b/backend/src/models/base.model.ts @@ -0,0 +1,16 @@ +import { Model, ModelOptions, QueryContext } from 'objection'; + +export class BaseModel extends Model { + 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(); + } +} diff --git a/backend/src/models/field-definition.model.ts b/backend/src/models/field-definition.model.ts new file mode 100644 index 0000000..ea58fef --- /dev/null +++ b/backend/src/models/field-definition.model.ts @@ -0,0 +1,33 @@ +import { BaseModel } from './base.model'; + +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; + + static relationMappings = { + objectDefinition: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'object-definition.model', + join: { + from: 'field_definitions.objectDefinitionId', + to: 'object_definitions.id', + }, + }, + }; +} diff --git a/backend/src/models/object-definition.model.ts b/backend/src/models/object-definition.model.ts new file mode 100644 index 0000000..7f5516b --- /dev/null +++ b/backend/src/models/object-definition.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/backend/src/models/permission.model.ts b/backend/src/models/permission.model.ts new file mode 100644 index 0000000..7753d9e --- /dev/null +++ b/backend/src/models/permission.model.ts @@ -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', + }, + }, + }; +} diff --git a/backend/src/models/role-permission.model.ts b/backend/src/models/role-permission.model.ts new file mode 100644 index 0000000..ac2efa7 --- /dev/null +++ b/backend/src/models/role-permission.model.ts @@ -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', + }, + }, + }; +} diff --git a/backend/src/models/role.model.ts b/backend/src/models/role.model.ts new file mode 100644 index 0000000..4d55bb6 --- /dev/null +++ b/backend/src/models/role.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/backend/src/models/user-role.model.ts b/backend/src/models/user-role.model.ts new file mode 100644 index 0000000..1776624 --- /dev/null +++ b/backend/src/models/user-role.model.ts @@ -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', + }, + }, + }; +} diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts new file mode 100644 index 0000000..ab98e1d --- /dev/null +++ b/backend/src/models/user.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/backend/src/object/object.module.ts b/backend/src/object/object.module.ts index 6587540..43678f0 100644 --- a/backend/src/object/object.module.ts +++ b/backend/src/object/object.module.ts @@ -2,10 +2,13 @@ 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 { TenantModule } from '../tenant/tenant.module'; @Module({ - providers: [ObjectService], + imports: [TenantModule], + providers: [ObjectService, SchemaManagementService], controllers: [RuntimeObjectController, SetupObjectController], - exports: [ObjectService], + exports: [ObjectService, SchemaManagementService], }) export class ObjectModule {} diff --git a/backend/src/object/schema-management.service.ts b/backend/src/object/schema-management.service.ts new file mode 100644 index 0000000..00a9cbe --- /dev/null +++ b/backend/src/object/schema-management.service.ts @@ -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.addFieldToTable(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.addFieldToTable(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 addFieldToTable( + 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; + } +} diff --git a/backend/src/prisma/central-prisma.service.ts b/backend/src/prisma/central-prisma.service.ts new file mode 100644 index 0000000..d93fe5f --- /dev/null +++ b/backend/src/prisma/central-prisma.service.ts @@ -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(); + } +} diff --git a/backend/src/tenant/tenant-database.service.ts b/backend/src/tenant/tenant-database.service.ts new file mode 100644 index 0000000..5f5787b --- /dev/null +++ b/backend/src/tenant/tenant-database.service.ts @@ -0,0 +1,124 @@ +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 = new Map(); + + async getTenantKnex(tenantId: string): Promise { + if (this.tenantConnections.has(tenantId)) { + return this.tenantConnections.get(tenantId); + } + + const centralPrisma = getCentralPrisma(); + const tenant = await centralPrisma.tenant.findUnique({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new Error(`Tenant ${tenantId} not found`); + } + + if (tenant.status !== 'active') { + throw new Error(`Tenant ${tenantId} 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(tenantId, tenantKnex); + return tenantKnex; + } + + async getTenantByDomain(domain: string): Promise { + 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; + } +} diff --git a/backend/src/tenant/tenant-provisioning.controller.ts b/backend/src/tenant/tenant-provisioning.controller.ts new file mode 100644 index 0000000..2fe4312 --- /dev/null +++ b/backend/src/tenant/tenant-provisioning.controller.ts @@ -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 }; + } +} diff --git a/backend/src/tenant/tenant-provisioning.service.ts b/backend/src/tenant/tenant-provisioning.service.ts new file mode 100644 index 0000000..e379f57 --- /dev/null +++ b/backend/src/tenant/tenant-provisioning.service.ts @@ -0,0 +1,342 @@ +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] = await tenantKnex('roles').insert({ + id: tenantKnex.raw('(UUID())'), + name: 'Admin', + guardName: 'api', + description: 'Full system administrator access', + created_at: tenantKnex.fn.now(), + updated_at: tenantKnex.fn.now(), + }); + + const [userRoleId] = await tenantKnex('roles').insert({ + id: tenantKnex.raw('(UUID())'), + 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: tenantKnex.raw('(UUID())'), + 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: tenantKnex.raw('(UUID())'), + 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: tenantKnex.raw('(UUID())'), + 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; + } + } +} diff --git a/backend/src/tenant/tenant.middleware.ts b/backend/src/tenant/tenant.middleware.ts index 23455aa..8e4ce3d 100644 --- a/backend/src/tenant/tenant.middleware.ts +++ b/backend/src/tenant/tenant.middleware.ts @@ -1,16 +1,64 @@ -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('.'); + + // For local development, accept x-tenant-id header as fallback + let tenantId = req.headers['x-tenant-id'] as string; + let subdomain: string | null = null; + + // Extract subdomain (e.g., "acme" from "acme.routebox.co") + if (parts.length > 2) { + subdomain = parts[0]; + // Ignore www subdomain + if (subdomain === 'www') { + subdomain = null; + } + } + + // Get tenant by subdomain if available + if (subdomain) { + const tenant = await this.tenantDbService.getTenantByDomain(subdomain); + if (tenant) { + tenantId = tenant.id; + this.logger.log( + `Tenant identified: ${tenant.name} (${tenant.id}) from subdomain: ${subdomain}`, + ); + } else { + this.logger.warn(`No tenant found for subdomain: ${subdomain}`); + } + } + + 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(); } } diff --git a/backend/src/tenant/tenant.module.ts b/backend/src/tenant/tenant.module.ts index cb091c5..a2ad485 100644 --- a/backend/src/tenant/tenant.module.ts +++ b/backend/src/tenant/tenant.module.ts @@ -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('*'); diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 41db92f..e063038 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -43,14 +43,14 @@ services: container_name: platform-db restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: root + MYSQL_ROOT_PASSWORD: "asjdnfqTash37faggT" MYSQL_DATABASE: platform MYSQL_USER: platform MYSQL_PASSWORD: platform ports: - "3306:3306" - volumes: - - percona-data:/var/lib/mysql + ##volumes: + ##- percona-data:/var/lib/mysql networks: - platform-network diff --git a/test-multi-tenant.sh b/test-multi-tenant.sh new file mode 100755 index 0000000..1c54d0d --- /dev/null +++ b/test-multi-tenant.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +# Test Tenant Provisioning Script +# This script tests the complete multi-tenant setup + +set -e + +echo "🚀 Testing Multi-Tenant Setup" +echo "==============================" +echo "" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Check if backend is running +echo -e "${BLUE}Checking if backend is running...${NC}" +if ! curl -s http://jupiter.routebox.co:3000 > /dev/null; then + echo -e "${RED}Backend is not running. Please start it with: cd backend && npm run dev${NC}" + exit 1 +fi +echo -e "${GREEN}✓ Backend is running${NC}" +echo "" + +# Create test tenant +echo -e "${BLUE}Creating test tenant: 'acme'${NC}" +RESPONSE=$(curl -s -X POST http://localhost:3000/setup/tenants \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Acme Corporation", + "slug": "acme", + "primaryDomain": "acme" + }') + +if echo "$RESPONSE" | grep -q "tenantId"; then + echo -e "${GREEN}✓ Tenant created successfully${NC}" + echo "$RESPONSE" | node -e " + const fs = require('fs'); + const data = JSON.parse(fs.readFileSync(0, 'utf-8')); + console.log(' Tenant ID:', data.tenantId); + console.log(' Database:', data.dbName); + console.log(' Username:', data.dbUsername); + " +else + echo -e "${RED}✗ Failed to create tenant${NC}" + echo "$RESPONSE" + exit 1 +fi +echo "" + +# Verify tenant database was created +echo -e "${BLUE}Verifying tenant database...${NC}" +DB_EXISTS=$(docker exec platform-db mysql -uroot -proot -e "SHOW DATABASES LIKE 'tenant_acme';" | grep tenant_acme || echo "") +if [ -n "$DB_EXISTS" ]; then + echo -e "${GREEN}✓ Database 'tenant_acme' exists${NC}" +else + echo -e "${RED}✗ Database 'tenant_acme' not found${NC}" + exit 1 +fi +echo "" + +# Check if tables were created +echo -e "${BLUE}Checking tenant schema...${NC}" +TABLES=$(docker exec platform-db mysql -uroot -proot tenant_acme -e "SHOW TABLES;" 2>/dev/null || echo "") +if [ -n "$TABLES" ]; then + echo -e "${GREEN}✓ Tenant tables created:${NC}" + echo "$TABLES" | tail -n +2 | sed 's/^/ - /' +else + echo -e "${RED}✗ No tables found in tenant database${NC}" + exit 1 +fi +echo "" + +# Check if default roles were seeded +echo -e "${BLUE}Checking default data...${NC}" +ROLES=$(docker exec platform-db mysql -uroot -proot tenant_acme -e "SELECT name FROM roles;" 2>/dev/null | tail -n +2 || echo "") +if [ -n "$ROLES" ]; then + echo -e "${GREEN}✓ Default roles created:${NC}" + echo "$ROLES" | sed 's/^/ - /' +else + echo -e "${RED}✗ No default roles found${NC}" + exit 1 +fi +echo "" + +# Verify central database entries +echo -e "${BLUE}Verifying central database...${NC}" +TENANT_RECORD=$(docker exec platform-db mysql -uroot -proot central_platform -e "SELECT name, slug, status FROM tenants WHERE slug='acme';" 2>/dev/null | tail -n +2 || echo "") +if [ -n "$TENANT_RECORD" ]; then + echo -e "${GREEN}✓ Tenant record in central database:${NC}" + echo " $TENANT_RECORD" +else + echo -e "${RED}✗ Tenant record not found in central database${NC}" + exit 1 +fi +echo "" + +DOMAIN_RECORD=$(docker exec platform-db mysql -uroot -proot central_platform -e "SELECT domain, isPrimary FROM domains WHERE domain='acme';" 2>/dev/null | tail -n +2 || echo "") +if [ -n "$DOMAIN_RECORD" ]; then + echo -e "${GREEN}✓ Domain mapping exists:${NC}" + echo " $DOMAIN_RECORD" +else + echo -e "${RED}✗ Domain mapping not found${NC}" + exit 1 +fi +echo "" + +echo -e "${GREEN}==============================" +echo "✓ All tests passed!" +echo "==============================${NC}" +echo "" +echo "Next steps:" +echo " 1. Update /etc/hosts: 127.0.0.1 acme.localhost" +echo " 2. Access tenant at: http://acme.localhost:3000" +echo " 3. Check logs: docker logs platform-api" +echo "" +echo "To clean up:" +echo " curl -X DELETE http://localhost:3000/setup/tenants/"