WIP - multitenant initial work

This commit is contained in:
Francisco Gaona
2025-11-29 05:09:00 +01:00
parent 859dca6c84
commit 57f27d28cd
36 changed files with 2784 additions and 36 deletions

View File

@@ -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="<generated-32-byte-hex-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

115
MULTI_TENANT_MIGRATION.md Normal file
View File

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

20
backend/.env.example Normal file
View File

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

19
backend/knexfile.js Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
exports.up = function (knex) {
return knex.schema
.createTable('apps', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('slug', 255).notNullable().unique();
table.string('label', 255).notNullable();
table.text('description');
table.integer('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');
};

View File

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

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,216 @@
import { Injectable, Logger } from '@nestjs/common';
import { Knex } from 'knex';
import { ObjectDefinition } from '../models/object-definition.model';
import { FieldDefinition } from '../models/field-definition.model';
@Injectable()
export class SchemaManagementService {
private readonly logger = new Logger(SchemaManagementService.name);
/**
* Create a physical table for an object definition
*/
async createObjectTable(
knex: Knex,
objectDefinition: ObjectDefinition,
fields: FieldDefinition[],
) {
const tableName = this.getTableName(objectDefinition.apiName);
// Check if table already exists
const exists = await knex.schema.hasTable(tableName);
if (exists) {
throw new Error(`Table ${tableName} already exists`);
}
await knex.schema.createTable(tableName, (table) => {
// Standard fields
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.timestamps(true, true);
// Custom fields from field definitions
for (const field of fields) {
this.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;
}
}

View File

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

View File

@@ -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<string, Knex> = new Map();
async getTenantKnex(tenantId: string): Promise<Knex> {
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<any> {
const centralPrisma = getCentralPrisma();
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: true },
});
if (!domainRecord) {
throw new Error(`Domain ${domain} not found`);
}
if (domainRecord.tenant.status !== 'active') {
throw new Error(`Tenant for domain ${domain} is not active`);
}
return domainRecord.tenant;
}
async disconnectTenant(tenantId: string) {
const connection = this.tenantConnections.get(tenantId);
if (connection) {
await connection.destroy();
this.tenantConnections.delete(tenantId);
this.logger.log(`Disconnected tenant: ${tenantId}`);
}
}
removeTenantConnection(tenantId: string) {
this.tenantConnections.delete(tenantId);
this.logger.log(`Removed tenant connection from cache: ${tenantId}`);
}
async disconnectAll() {
for (const [tenantId, connection] of this.tenantConnections.entries()) {
await connection.destroy();
}
this.tenantConnections.clear();
this.logger.log('Disconnected all tenant connections');
}
encryptPassword(password: string): string {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(password, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
private decryptPassword(encryptedPassword: string): string {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}

View File

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

View File

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

View File

@@ -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;
private readonly logger = new Logger(TenantMiddleware.name);
if (tenantId) {
// Attach tenantId to request object
(req as any).tenantId = tenantId;
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();
}
}

View File

@@ -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('*');

View File

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

120
test-multi-tenant.sh Executable file
View File

@@ -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/<tenant-id>"