Compare commits
4 Commits
c50098a55c
...
worktree-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0aeb948b | ||
|
|
0ad62cbf8d | ||
|
|
5a80f33078 | ||
|
|
57f27d28cd |
315
MULTI_TENANT_IMPLEMENTATION.md
Normal file
315
MULTI_TENANT_IMPLEMENTATION.md
Normal 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
115
MULTI_TENANT_MIGRATION.md
Normal 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
20
backend/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Central Database (Prisma - stores tenant metadata)
|
||||||
|
CENTRAL_DATABASE_URL="mysql://user:password@platform-db:3306/central_platform"
|
||||||
|
|
||||||
|
# Database Root Credentials (for tenant provisioning)
|
||||||
|
DB_HOST="platform-db"
|
||||||
|
DB_PORT="3306"
|
||||||
|
DB_ROOT_USER="root"
|
||||||
|
DB_ROOT_PASSWORD="root"
|
||||||
|
|
||||||
|
# Encryption Key for Tenant Database Passwords (32-byte hex string)
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
ENCRYPTION_KEY="your-32-byte-hex-encryption-key-here"
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET="your-jwt-secret"
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV="development"
|
||||||
|
PORT="3000"
|
||||||
19
backend/knexfile.js
Normal file
19
backend/knexfile.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
development: {
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || 'root',
|
||||||
|
database: process.env.DB_NAME || 'tenant_template',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
directory: './migrations/tenant',
|
||||||
|
tableName: 'knex_migrations',
|
||||||
|
},
|
||||||
|
seeds: {
|
||||||
|
directory: './seeds/tenant',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.createTable('users', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('email', 255).notNullable();
|
||||||
|
table.string('password', 255).notNullable();
|
||||||
|
table.string('firstName', 255);
|
||||||
|
table.string('lastName', 255);
|
||||||
|
table.boolean('isActive').defaultTo(true);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.unique(['email']);
|
||||||
|
table.index(['email']);
|
||||||
|
})
|
||||||
|
.createTable('roles', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.string('guardName', 255).defaultTo('api');
|
||||||
|
table.text('description');
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.unique(['name', 'guardName']);
|
||||||
|
})
|
||||||
|
.createTable('permissions', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.string('guardName', 255).defaultTo('api');
|
||||||
|
table.text('description');
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.unique(['name', 'guardName']);
|
||||||
|
})
|
||||||
|
.createTable('role_permissions', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('roleId').notNullable();
|
||||||
|
table.uuid('permissionId').notNullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('roleId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('roles')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('permissionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('permissions')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.unique(['roleId', 'permissionId']);
|
||||||
|
})
|
||||||
|
.createTable('user_roles', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('userId').notNullable();
|
||||||
|
table.uuid('roleId').notNullable();
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('userId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table
|
||||||
|
.foreign('roleId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('roles')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.unique(['userId', 'roleId']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.dropTableIfExists('user_roles')
|
||||||
|
.dropTableIfExists('role_permissions')
|
||||||
|
.dropTableIfExists('permissions')
|
||||||
|
.dropTableIfExists('roles')
|
||||||
|
.dropTableIfExists('users');
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.createTable('object_definitions', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('apiName', 255).notNullable().unique();
|
||||||
|
table.string('label', 255).notNullable();
|
||||||
|
table.string('pluralLabel', 255);
|
||||||
|
table.text('description');
|
||||||
|
table.boolean('isSystem').defaultTo(false);
|
||||||
|
table.boolean('isCustom').defaultTo(true);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.index(['apiName']);
|
||||||
|
})
|
||||||
|
.createTable('field_definitions', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('objectDefinitionId').notNullable();
|
||||||
|
table.string('apiName', 255).notNullable();
|
||||||
|
table.string('label', 255).notNullable();
|
||||||
|
table.string('type', 50).notNullable(); // String, Number, Date, Boolean, Reference, etc.
|
||||||
|
table.integer('length');
|
||||||
|
table.integer('precision');
|
||||||
|
table.integer('scale');
|
||||||
|
table.string('referenceObject', 255);
|
||||||
|
table.text('defaultValue');
|
||||||
|
table.text('description');
|
||||||
|
table.boolean('isRequired').defaultTo(false);
|
||||||
|
table.boolean('isUnique').defaultTo(false);
|
||||||
|
table.boolean('isSystem').defaultTo(false);
|
||||||
|
table.boolean('isCustom').defaultTo(true);
|
||||||
|
table.integer('displayOrder').defaultTo(0);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('objectDefinitionId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('object_definitions')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.unique(['objectDefinitionId', 'apiName']);
|
||||||
|
table.index(['objectDefinitionId']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.dropTableIfExists('field_definitions')
|
||||||
|
.dropTableIfExists('object_definitions');
|
||||||
|
};
|
||||||
35
backend/migrations/tenant/20250126000003_create_apps.js
Normal file
35
backend/migrations/tenant/20250126000003_create_apps.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.createTable('apps', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('slug', 255).notNullable().unique();
|
||||||
|
table.string('label', 255).notNullable();
|
||||||
|
table.text('description');
|
||||||
|
table.integer('display_order').defaultTo(0);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table.index(['slug']);
|
||||||
|
})
|
||||||
|
.createTable('app_pages', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.uuid('app_id').notNullable();
|
||||||
|
table.string('slug', 255).notNullable();
|
||||||
|
table.string('label', 255).notNullable();
|
||||||
|
table.string('type', 50).notNullable(); // List, Detail, Custom
|
||||||
|
table.string('object_api_name', 255);
|
||||||
|
table.integer('display_order').defaultTo(0);
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('app_id')
|
||||||
|
.references('id')
|
||||||
|
.inTable('apps')
|
||||||
|
.onDelete('CASCADE');
|
||||||
|
table.unique(['app_id', 'slug']);
|
||||||
|
table.index(['app_id']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.dropTableIfExists('app_pages').dropTableIfExists('apps');
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
// Create standard Account object
|
||||||
|
await knex.schema.createTable('accounts', (table) => {
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.string('name', 255).notNullable();
|
||||||
|
table.string('website', 255);
|
||||||
|
table.string('phone', 50);
|
||||||
|
table.string('industry', 100);
|
||||||
|
table.uuid('ownerId');
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
table
|
||||||
|
.foreign('ownerId')
|
||||||
|
.references('id')
|
||||||
|
.inTable('users')
|
||||||
|
.onDelete('SET NULL');
|
||||||
|
table.index(['name']);
|
||||||
|
table.index(['ownerId']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert Account object definition
|
||||||
|
const [objectId] = await knex('object_definitions').insert({
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
apiName: 'Account',
|
||||||
|
label: 'Account',
|
||||||
|
pluralLabel: 'Accounts',
|
||||||
|
description: 'Standard Account object',
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert Account field definitions
|
||||||
|
const objectDefId =
|
||||||
|
objectId ||
|
||||||
|
(await knex('object_definitions').where('apiName', 'Account').first()).id;
|
||||||
|
|
||||||
|
await knex('field_definitions').insert([
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
apiName: 'name',
|
||||||
|
label: 'Account Name',
|
||||||
|
type: 'String',
|
||||||
|
length: 255,
|
||||||
|
isRequired: true,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 1,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
apiName: 'website',
|
||||||
|
label: 'Website',
|
||||||
|
type: 'String',
|
||||||
|
length: 255,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 2,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
apiName: 'phone',
|
||||||
|
label: 'Phone',
|
||||||
|
type: 'String',
|
||||||
|
length: 50,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 3,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
apiName: 'industry',
|
||||||
|
label: 'Industry',
|
||||||
|
type: 'String',
|
||||||
|
length: 100,
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 4,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: knex.raw('(UUID())'),
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
apiName: 'ownerId',
|
||||||
|
label: 'Owner',
|
||||||
|
type: 'Reference',
|
||||||
|
referenceObject: 'User',
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
displayOrder: 5,
|
||||||
|
created_at: knex.fn.now(),
|
||||||
|
updated_at: knex.fn.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.dropTableIfExists('accounts');
|
||||||
|
};
|
||||||
341
backend/package-lock.json
generated
341
backend/package-lock.json
generated
@@ -22,6 +22,9 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"knex": "^3.1.0",
|
||||||
|
"mysql2": "^3.15.3",
|
||||||
|
"objection": "^3.1.5",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
@@ -3341,6 +3344,15 @@
|
|||||||
"fastq": "^1.17.1"
|
"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": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
@@ -4016,6 +4028,12 @@
|
|||||||
"color-support": "bin.js"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@@ -4167,6 +4185,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -4473,7 +4497,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -4684,6 +4707,15 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/espree": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||||
@@ -5317,7 +5349,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -5350,6 +5381,15 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -5399,7 +5439,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||||
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
|
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
@@ -5432,6 +5471,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@@ -5640,7 +5685,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -5813,6 +5857,15 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
||||||
@@ -5870,7 +5923,6 @@
|
|||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hasown": "^2.0.2"
|
"hasown": "^2.0.2"
|
||||||
@@ -5954,6 +6006,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@@ -6983,6 +7041,98 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@@ -7168,6 +7318,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -7178,6 +7334,21 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/luxon": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
@@ -7473,6 +7644,63 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@@ -7618,6 +7846,55 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/obliterator": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
@@ -7860,7 +8137,6 @@
|
|||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-scurry": {
|
"node_modules/path-scurry": {
|
||||||
@@ -7908,6 +8184,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8309,6 +8591,18 @@
|
|||||||
"node": ">= 12.13.0"
|
"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": {
|
"node_modules/redis-errors": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
@@ -8369,7 +8663,6 @@
|
|||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-core-module": "^2.16.1",
|
"is-core-module": "^2.16.1",
|
||||||
@@ -8619,7 +8912,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/schema-utils": {
|
"node_modules/schema-utils": {
|
||||||
@@ -8693,6 +8985,11 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/serialize-javascript": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||||
@@ -8842,6 +9139,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@@ -9015,7 +9321,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -9096,6 +9401,15 @@
|
|||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/terser": {
|
||||||
"version": "5.44.1",
|
"version": "5.44.1",
|
||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||||
@@ -9292,6 +9606,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tmp": {
|
||||||
"version": "0.0.33",
|
"version": "0.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||||
|
|||||||
@@ -20,21 +20,24 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.3.0",
|
"@nestjs/core": "^10.3.0",
|
||||||
"@nestjs/platform-fastify": "^10.3.0",
|
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/platform-fastify": "^10.3.0",
|
||||||
"@nestjs/bullmq": "^10.1.0",
|
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
"passport": "^0.7.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.1.0",
|
"bullmq": "^5.1.0",
|
||||||
"ioredis": "^5.3.2",
|
|
||||||
"class-validator": "^0.14.1",
|
|
||||||
"class-transformer": "^0.5.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",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -42,11 +45,11 @@
|
|||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
"@nestjs/schematics": "^10.1.0",
|
"@nestjs/schematics": "^10.1.0",
|
||||||
"@nestjs/testing": "^10.3.0",
|
"@nestjs/testing": "^10.3.0",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/passport-jwt": "^4.0.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `isActive` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `accounts` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `app_pages` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `apps` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `field_definitions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `object_definitions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `permissions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `role_permissions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `roles` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `user_roles` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `users` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- Added the required column `dbHost` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `dbName` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `dbPassword` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `dbUsername` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_ownerId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_appId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_objectId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `apps` DROP FOREIGN KEY `apps_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `field_definitions` DROP FOREIGN KEY `field_definitions_objectId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `object_definitions` DROP FOREIGN KEY `object_definitions_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `permissions` DROP FOREIGN KEY `permissions_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_permissionId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_roleId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `roles` DROP FOREIGN KEY `roles_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_roleId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_userId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `users` DROP FOREIGN KEY `users_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `tenants` DROP COLUMN `isActive`,
|
||||||
|
ADD COLUMN `dbHost` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `dbName` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `dbPassword` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `dbPort` INTEGER NOT NULL DEFAULT 3306,
|
||||||
|
ADD COLUMN `dbUsername` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `status` VARCHAR(191) NOT NULL DEFAULT 'active';
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `accounts`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `app_pages`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `apps`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `field_definitions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `object_definitions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `permissions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `role_permissions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `roles`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `user_roles`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `users`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `domains` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`domain` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`isPrimary` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `domains_domain_key`(`domain`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `domains` ADD CONSTRAINT `domains_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
238
backend/prisma/migrations/20251129033827_init/migration.sql
Normal file
238
backend/prisma/migrations/20251129033827_init/migration.sql
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `dbHost` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dbName` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dbPassword` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dbPort` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dbUsername` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `status` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `domains` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `domains` DROP FOREIGN KEY `domains_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `tenants` DROP COLUMN `dbHost`,
|
||||||
|
DROP COLUMN `dbName`,
|
||||||
|
DROP COLUMN `dbPassword`,
|
||||||
|
DROP COLUMN `dbPort`,
|
||||||
|
DROP COLUMN `dbUsername`,
|
||||||
|
DROP COLUMN `status`,
|
||||||
|
ADD COLUMN `isActive` BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `domains`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`password` VARCHAR(191) NOT NULL,
|
||||||
|
`firstName` VARCHAR(191) NULL,
|
||||||
|
`lastName` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `users_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `users_tenantId_email_key`(`tenantId`, `email`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `roles` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`guardName` VARCHAR(191) NOT NULL DEFAULT 'api',
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `roles_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `roles_tenantId_name_guardName_key`(`tenantId`, `name`, `guardName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `permissions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`guardName` VARCHAR(191) NOT NULL DEFAULT 'api',
|
||||||
|
`description` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `permissions_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `permissions_tenantId_name_guardName_key`(`tenantId`, `name`, `guardName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `user_roles` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`roleId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `user_roles_userId_idx`(`userId`),
|
||||||
|
INDEX `user_roles_roleId_idx`(`roleId`),
|
||||||
|
UNIQUE INDEX `user_roles_userId_roleId_key`(`userId`, `roleId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `role_permissions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`roleId` VARCHAR(191) NOT NULL,
|
||||||
|
`permissionId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `role_permissions_roleId_idx`(`roleId`),
|
||||||
|
INDEX `role_permissions_permissionId_idx`(`permissionId`),
|
||||||
|
UNIQUE INDEX `role_permissions_roleId_permissionId_key`(`roleId`, `permissionId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `object_definitions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`apiName` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`pluralLabel` VARCHAR(191) NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`isSystem` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`tableName` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `object_definitions_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `object_definitions_tenantId_apiName_key`(`tenantId`, `apiName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `field_definitions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`objectId` VARCHAR(191) NOT NULL,
|
||||||
|
`apiName` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`isRequired` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isUnique` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isReadonly` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`isLookup` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`referenceTo` VARCHAR(191) NULL,
|
||||||
|
`defaultValue` VARCHAR(191) NULL,
|
||||||
|
`options` JSON NULL,
|
||||||
|
`validationRules` JSON NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `field_definitions_objectId_idx`(`objectId`),
|
||||||
|
UNIQUE INDEX `field_definitions_objectId_apiName_key`(`objectId`, `apiName`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`status` VARCHAR(191) NOT NULL DEFAULT 'active',
|
||||||
|
`ownerId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `accounts_tenantId_idx`(`tenantId`),
|
||||||
|
INDEX `accounts_ownerId_idx`(`ownerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `apps` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`slug` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`icon` VARCHAR(191) NULL,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `apps_tenantId_idx`(`tenantId`),
|
||||||
|
UNIQUE INDEX `apps_tenantId_slug_key`(`tenantId`, `slug`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `app_pages` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`appId` VARCHAR(191) NOT NULL,
|
||||||
|
`slug` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL,
|
||||||
|
`objectApiName` VARCHAR(191) NULL,
|
||||||
|
`objectId` VARCHAR(191) NULL,
|
||||||
|
`config` JSON NULL,
|
||||||
|
`sortOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `app_pages_appId_idx`(`appId`),
|
||||||
|
INDEX `app_pages_objectId_idx`(`objectId`),
|
||||||
|
UNIQUE INDEX `app_pages_appId_slug_key`(`appId`, `slug`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `users` ADD CONSTRAINT `users_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `roles` ADD CONSTRAINT `roles_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `permissions` ADD CONSTRAINT `permissions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_permissionId_fkey` FOREIGN KEY (`permissionId`) REFERENCES `permissions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `object_definitions` ADD CONSTRAINT `object_definitions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `field_definitions` ADD CONSTRAINT `field_definitions_objectId_fkey` FOREIGN KEY (`objectId`) REFERENCES `object_definitions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `apps` ADD CONSTRAINT `apps_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `app_pages` ADD CONSTRAINT `app_pages_appId_fkey` FOREIGN KEY (`appId`) REFERENCES `apps`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `app_pages` ADD CONSTRAINT `app_pages_objectId_fkey` FOREIGN KEY (`objectId`) REFERENCES `object_definitions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `isActive` on the `tenants` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `accounts` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `app_pages` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `apps` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `field_definitions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `object_definitions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `permissions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `role_permissions` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `roles` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `user_roles` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `users` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- Added the required column `dbHost` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `dbName` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `dbPassword` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `dbUsername` to the `tenants` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_ownerId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_appId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_objectId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `apps` DROP FOREIGN KEY `apps_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `field_definitions` DROP FOREIGN KEY `field_definitions_objectId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `object_definitions` DROP FOREIGN KEY `object_definitions_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `permissions` DROP FOREIGN KEY `permissions_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_permissionId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_roleId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `roles` DROP FOREIGN KEY `roles_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_roleId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_userId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `users` DROP FOREIGN KEY `users_tenantId_fkey`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `tenants` DROP COLUMN `isActive`,
|
||||||
|
ADD COLUMN `dbHost` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `dbName` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `dbPassword` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `dbPort` INTEGER NOT NULL DEFAULT 3306,
|
||||||
|
ADD COLUMN `dbUsername` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `status` VARCHAR(191) NOT NULL DEFAULT 'active';
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `accounts`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `app_pages`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `apps`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `field_definitions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `object_definitions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `permissions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `role_permissions`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `roles`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `user_roles`;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE `users`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `domains` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`domain` VARCHAR(191) NOT NULL,
|
||||||
|
`tenantId` VARCHAR(191) NOT NULL,
|
||||||
|
`isPrimary` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `domains_domain_key`(`domain`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `domains` ADD CONSTRAINT `domains_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
40
backend/prisma/schema-central.prisma
Normal file
40
backend/prisma/schema-central.prisma
Normal 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")
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
// This is your Prisma schema file,
|
// Tenant-specific database schema
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// This schema is applied to each tenant's database
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
output = "../node_modules/.prisma/tenant"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "mysql"
|
provider = "mysql"
|
||||||
url = env("DATABASE_URL")
|
url = env("TENANT_DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-tenancy
|
// Multi-tenancy
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AppBuilderService } from './app-builder.service';
|
import { AppBuilderService } from './app-builder.service';
|
||||||
import { RuntimeAppController } from './runtime-app.controller';
|
import { RuntimeAppController } from './runtime-app.controller';
|
||||||
import { SetupAppController } from './setup-app.controller';
|
import { SetupAppController } from './setup-app.controller';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [TenantModule],
|
||||||
providers: [AppBuilderService],
|
providers: [AppBuilderService],
|
||||||
controllers: [RuntimeAppController, SetupAppController],
|
controllers: [RuntimeAppController, SetupAppController],
|
||||||
exports: [AppBuilderService],
|
exports: [AppBuilderService],
|
||||||
|
|||||||
@@ -1,44 +1,26 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
|
import { App } from '../models/app.model';
|
||||||
|
import { AppPage } from '../models/app-page.model';
|
||||||
|
import { ObjectDefinition } from '../models/object-definition.model';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppBuilderService {
|
export class AppBuilderService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
// Runtime endpoints
|
// Runtime endpoints
|
||||||
async getApps(tenantId: string, userId: string) {
|
async getApps(tenantId: string, userId: string) {
|
||||||
// For now, return all active apps for the tenant
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
// For now, return all apps
|
||||||
// In production, you'd filter by user permissions
|
// In production, you'd filter by user permissions
|
||||||
return this.prisma.app.findMany({
|
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
|
||||||
where: {
|
|
||||||
tenantId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
pages: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { label: 'asc' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getApp(tenantId: string, slug: string, userId: string) {
|
async getApp(tenantId: string, slug: string, userId: string) {
|
||||||
const app = await this.prisma.app.findUnique({
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
where: {
|
const app = await App.query(knex)
|
||||||
tenantId_slug: {
|
.findOne({ slug })
|
||||||
tenantId,
|
.withGraphFetched('pages');
|
||||||
slug,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
pages: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new NotFoundException(`App ${slug} not found`);
|
throw new NotFoundException(`App ${slug} not found`);
|
||||||
@@ -53,23 +35,12 @@ export class AppBuilderService {
|
|||||||
pageSlug: string,
|
pageSlug: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
) {
|
) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getApp(tenantId, appSlug, userId);
|
const app = await this.getApp(tenantId, appSlug, userId);
|
||||||
|
|
||||||
const page = await this.prisma.appPage.findFirst({
|
const page = await AppPage.query(knex).findOne({
|
||||||
where: {
|
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
slug: pageSlug,
|
slug: pageSlug,
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
object: {
|
|
||||||
include: {
|
|
||||||
fields: {
|
|
||||||
where: { isActive: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -81,31 +52,15 @@ export class AppBuilderService {
|
|||||||
|
|
||||||
// Setup endpoints
|
// Setup endpoints
|
||||||
async getAllApps(tenantId: string) {
|
async getAllApps(tenantId: string) {
|
||||||
return this.prisma.app.findMany({
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
where: { tenantId },
|
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
|
||||||
include: {
|
|
||||||
pages: {
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { label: 'asc' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAppForSetup(tenantId: string, slug: string) {
|
async getAppForSetup(tenantId: string, slug: string) {
|
||||||
const app = await this.prisma.app.findUnique({
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
where: {
|
const app = await App.query(knex)
|
||||||
tenantId_slug: {
|
.findOne({ slug })
|
||||||
tenantId,
|
.withGraphFetched('pages');
|
||||||
slug,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
pages: {
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new NotFoundException(`App ${slug} not found`);
|
throw new NotFoundException(`App ${slug} not found`);
|
||||||
@@ -120,14 +75,12 @@ export class AppBuilderService {
|
|||||||
slug: string;
|
slug: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return this.prisma.app.create({
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
data: {
|
return App.query(knex).insert({
|
||||||
tenantId,
|
|
||||||
...data,
|
...data,
|
||||||
},
|
displayOrder: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,16 +90,12 @@ export class AppBuilderService {
|
|||||||
data: {
|
data: {
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getAppForSetup(tenantId, slug);
|
const app = await this.getAppForSetup(tenantId, slug);
|
||||||
|
|
||||||
return this.prisma.app.update({
|
return App.query(knex).patchAndFetchById(app.id, data);
|
||||||
where: { id: app.id },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPage(
|
async createPage(
|
||||||
@@ -157,37 +106,19 @@ export class AppBuilderService {
|
|||||||
label: string;
|
label: string;
|
||||||
type: string;
|
type: string;
|
||||||
objectApiName?: string;
|
objectApiName?: string;
|
||||||
config?: any;
|
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||||
|
|
||||||
// If objectApiName is provided, find the object
|
return AppPage.query(knex).insert({
|
||||||
let objectId: string | undefined;
|
|
||||||
if (data.objectApiName) {
|
|
||||||
const obj = await this.prisma.objectDefinition.findUnique({
|
|
||||||
where: {
|
|
||||||
tenantId_apiName: {
|
|
||||||
tenantId,
|
|
||||||
apiName: data.objectApiName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
objectId = obj?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.appPage.create({
|
|
||||||
data: {
|
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
objectApiName: data.objectApiName,
|
objectApiName: data.objectApiName,
|
||||||
objectId,
|
displayOrder: data.sortOrder || 0,
|
||||||
config: data.config,
|
|
||||||
sortOrder: data.sortOrder || 0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,44 +130,24 @@ export class AppBuilderService {
|
|||||||
label?: string;
|
label?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
objectApiName?: string;
|
objectApiName?: string;
|
||||||
config?: any;
|
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isActive?: boolean;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||||
|
|
||||||
const page = await this.prisma.appPage.findFirst({
|
const page = await AppPage.query(knex).findOne({
|
||||||
where: {
|
|
||||||
appId: app.id,
|
appId: app.id,
|
||||||
slug: pageSlug,
|
slug: pageSlug,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new NotFoundException(`Page ${pageSlug} not found`);
|
throw new NotFoundException(`Page ${pageSlug} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If objectApiName is provided, find the object
|
return AppPage.query(knex).patchAndFetchById(page.id, {
|
||||||
let objectId: string | undefined;
|
|
||||||
if (data.objectApiName) {
|
|
||||||
const obj = await this.prisma.objectDefinition.findUnique({
|
|
||||||
where: {
|
|
||||||
tenantId_apiName: {
|
|
||||||
tenantId,
|
|
||||||
apiName: data.objectApiName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
objectId = obj?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.appPage.update({
|
|
||||||
where: { id: page.id },
|
|
||||||
data: {
|
|
||||||
...data,
|
...data,
|
||||||
objectId,
|
displayOrder: data.sortOrder,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
|
|
||||||
@Controller('setup/apps')
|
@Controller('setup/apps')
|
||||||
@UseGuards(JwtAuthGuard)
|
//@UseGuards(JwtAuthGuard)
|
||||||
export class SetupAppController {
|
export class SetupAppController {
|
||||||
constructor(private appBuilderService: AppBuilderService) {}
|
constructor(private appBuilderService: AppBuilderService) {}
|
||||||
|
|
||||||
@@ -59,11 +59,6 @@ export class SetupAppController {
|
|||||||
@Param('pageSlug') pageSlug: string,
|
@Param('pageSlug') pageSlug: string,
|
||||||
@Body() data: any,
|
@Body() data: any,
|
||||||
) {
|
) {
|
||||||
return this.appBuilderService.updatePage(
|
return this.appBuilderService.updatePage(tenantId, appSlug, pageSlug, data);
|
||||||
tenantId,
|
|
||||||
appSlug,
|
|
||||||
pageSlug,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
backend/src/models/account.model.ts
Normal file
23
backend/src/models/account.model.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class Account extends BaseModel {
|
||||||
|
static tableName = 'accounts';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
website?: string;
|
||||||
|
phone?: string;
|
||||||
|
industry?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
owner: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'user.model',
|
||||||
|
join: {
|
||||||
|
from: 'accounts.ownerId',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
25
backend/src/models/app-page.model.ts
Normal file
25
backend/src/models/app-page.model.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { App } from './app.model';
|
||||||
|
|
||||||
|
export class AppPage extends BaseModel {
|
||||||
|
static tableName = 'app_pages';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
appId!: string;
|
||||||
|
slug!: string;
|
||||||
|
label!: string;
|
||||||
|
type!: string;
|
||||||
|
objectApiName?: string;
|
||||||
|
displayOrder!: number;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
app: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: App,
|
||||||
|
join: {
|
||||||
|
from: 'app_pages.appId',
|
||||||
|
to: 'apps.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
23
backend/src/models/app.model.ts
Normal file
23
backend/src/models/app.model.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
import { AppPage } from './app-page.model';
|
||||||
|
|
||||||
|
export class App extends BaseModel {
|
||||||
|
static tableName = 'apps';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
slug!: string;
|
||||||
|
label!: string;
|
||||||
|
description?: string;
|
||||||
|
displayOrder!: number;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
pages: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: AppPage,
|
||||||
|
join: {
|
||||||
|
from: 'apps.id',
|
||||||
|
to: 'app_pages.appId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
18
backend/src/models/base.model.ts
Normal file
18
backend/src/models/base.model.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
|
||||||
|
|
||||||
|
export class BaseModel extends Model {
|
||||||
|
static columnNameMappers = snakeCaseMappers();
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
$beforeInsert(queryContext: QueryContext) {
|
||||||
|
this.createdAt = new Date();
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/models/field-definition.model.ts
Normal file
33
backend/src/models/field-definition.model.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
46
backend/src/models/object-definition.model.ts
Normal file
46
backend/src/models/object-definition.model.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class ObjectDefinition extends BaseModel {
|
||||||
|
static tableName = 'object_definitions';
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
apiName: string;
|
||||||
|
label: string;
|
||||||
|
pluralLabel?: string;
|
||||||
|
description?: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
isCustom: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
static get jsonSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
required: ['apiName', 'label'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
apiName: { type: 'string' },
|
||||||
|
label: { type: 'string' },
|
||||||
|
pluralLabel: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
isSystem: { type: 'boolean' },
|
||||||
|
isCustom: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { FieldDefinition } = require('./field-definition.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
fields: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: FieldDefinition,
|
||||||
|
join: {
|
||||||
|
from: 'object_definitions.id',
|
||||||
|
to: 'field_definitions.objectDefinitionId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/models/permission.model.ts
Normal file
25
backend/src/models/permission.model.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class Permission extends BaseModel {
|
||||||
|
static tableName = 'permissions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
guardName!: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
roles: {
|
||||||
|
relation: BaseModel.ManyToManyRelation,
|
||||||
|
modelClass: 'role.model',
|
||||||
|
join: {
|
||||||
|
from: 'permissions.id',
|
||||||
|
through: {
|
||||||
|
from: 'role_permissions.permissionId',
|
||||||
|
to: 'role_permissions.roleId',
|
||||||
|
},
|
||||||
|
to: 'roles.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
28
backend/src/models/role-permission.model.ts
Normal file
28
backend/src/models/role-permission.model.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class RolePermission extends BaseModel {
|
||||||
|
static tableName = 'role_permissions';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
roleId!: string;
|
||||||
|
permissionId!: string;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
role: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'role.model',
|
||||||
|
join: {
|
||||||
|
from: 'role_permissions.roleId',
|
||||||
|
to: 'roles.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'permission.model',
|
||||||
|
join: {
|
||||||
|
from: 'role_permissions.permissionId',
|
||||||
|
to: 'permissions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
66
backend/src/models/role.model.ts
Normal file
66
backend/src/models/role.model.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class Role extends BaseModel {
|
||||||
|
static tableName = 'roles';
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
guardName: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
static get jsonSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
required: ['name'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
guardName: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { RolePermission } = require('./role-permission.model');
|
||||||
|
const { Permission } = require('./permission.model');
|
||||||
|
const { User } = require('./user.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
rolePermissions: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: RolePermission,
|
||||||
|
join: {
|
||||||
|
from: 'roles.id',
|
||||||
|
to: 'role_permissions.roleId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
relation: BaseModel.ManyToManyRelation,
|
||||||
|
modelClass: Permission,
|
||||||
|
join: {
|
||||||
|
from: 'roles.id',
|
||||||
|
through: {
|
||||||
|
from: 'role_permissions.roleId',
|
||||||
|
to: 'role_permissions.permissionId',
|
||||||
|
},
|
||||||
|
to: 'permissions.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
relation: BaseModel.ManyToManyRelation,
|
||||||
|
modelClass: User,
|
||||||
|
join: {
|
||||||
|
from: 'roles.id',
|
||||||
|
through: {
|
||||||
|
from: 'user_roles.roleId',
|
||||||
|
to: 'user_roles.userId',
|
||||||
|
},
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/models/user-role.model.ts
Normal file
28
backend/src/models/user-role.model.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class UserRole extends BaseModel {
|
||||||
|
static tableName = 'user_roles';
|
||||||
|
|
||||||
|
id!: string;
|
||||||
|
userId!: string;
|
||||||
|
roleId!: string;
|
||||||
|
|
||||||
|
static relationMappings = {
|
||||||
|
user: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'user.model',
|
||||||
|
join: {
|
||||||
|
from: 'user_roles.userId',
|
||||||
|
to: 'users.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
relation: BaseModel.BelongsToOneRelation,
|
||||||
|
modelClass: 'role.model',
|
||||||
|
join: {
|
||||||
|
from: 'user_roles.roleId',
|
||||||
|
to: 'roles.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
57
backend/src/models/user.model.ts
Normal file
57
backend/src/models/user.model.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
|
export class User extends BaseModel {
|
||||||
|
static tableName = 'users';
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
static get jsonSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
required: ['email', 'password'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
password: { type: 'string' },
|
||||||
|
firstName: { type: 'string' },
|
||||||
|
lastName: { type: 'string' },
|
||||||
|
isActive: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get relationMappings() {
|
||||||
|
const { UserRole } = require('./user-role.model');
|
||||||
|
const { Role } = require('./role.model');
|
||||||
|
|
||||||
|
return {
|
||||||
|
userRoles: {
|
||||||
|
relation: BaseModel.HasManyRelation,
|
||||||
|
modelClass: UserRole,
|
||||||
|
join: {
|
||||||
|
from: 'users.id',
|
||||||
|
to: 'user_roles.userId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
relation: BaseModel.ManyToManyRelation,
|
||||||
|
modelClass: Role,
|
||||||
|
join: {
|
||||||
|
from: 'users.id',
|
||||||
|
through: {
|
||||||
|
from: 'user_roles.userId',
|
||||||
|
to: 'user_roles.roleId',
|
||||||
|
},
|
||||||
|
to: 'roles.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ObjectService } from './object.service';
|
import { ObjectService } from './object.service';
|
||||||
import { RuntimeObjectController } from './runtime-object.controller';
|
import { RuntimeObjectController } from './runtime-object.controller';
|
||||||
import { SetupObjectController } from './setup-object.controller';
|
import { SetupObjectController } from './setup-object.controller';
|
||||||
|
import { SchemaManagementService } from './schema-management.service';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [ObjectService],
|
imports: [TenantModule],
|
||||||
|
providers: [ObjectService, SchemaManagementService],
|
||||||
controllers: [RuntimeObjectController, SetupObjectController],
|
controllers: [RuntimeObjectController, SetupObjectController],
|
||||||
exports: [ObjectService],
|
exports: [ObjectService, SchemaManagementService],
|
||||||
})
|
})
|
||||||
export class ObjectModule {}
|
export class ObjectModule {}
|
||||||
|
|||||||
216
backend/src/object/schema-management.service.ts
Normal file
216
backend/src/object/schema-management.service.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { ObjectDefinition } from '../models/object-definition.model';
|
||||||
|
import { FieldDefinition } from '../models/field-definition.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchemaManagementService {
|
||||||
|
private readonly logger = new Logger(SchemaManagementService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a physical table for an object definition
|
||||||
|
*/
|
||||||
|
async createObjectTable(
|
||||||
|
knex: Knex,
|
||||||
|
objectDefinition: ObjectDefinition,
|
||||||
|
fields: FieldDefinition[],
|
||||||
|
) {
|
||||||
|
const tableName = this.getTableName(objectDefinition.apiName);
|
||||||
|
|
||||||
|
// Check if table already exists
|
||||||
|
const exists = await knex.schema.hasTable(tableName);
|
||||||
|
if (exists) {
|
||||||
|
throw new Error(`Table ${tableName} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex.schema.createTable(tableName, (table) => {
|
||||||
|
// Standard fields
|
||||||
|
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
// Custom fields from field definitions
|
||||||
|
for (const field of fields) {
|
||||||
|
this.addFieldColumn(table, field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Created table: ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new field to an existing object table
|
||||||
|
*/
|
||||||
|
async addFieldToTable(
|
||||||
|
knex: Knex,
|
||||||
|
objectApiName: string,
|
||||||
|
field: FieldDefinition,
|
||||||
|
) {
|
||||||
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
|
this.addFieldColumn(table, field);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Added field ${field.apiName} to table ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a field from an existing object table
|
||||||
|
*/
|
||||||
|
async removeFieldFromTable(
|
||||||
|
knex: Knex,
|
||||||
|
objectApiName: string,
|
||||||
|
fieldApiName: string,
|
||||||
|
) {
|
||||||
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
|
table.dropColumn(fieldApiName);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop an object table
|
||||||
|
*/
|
||||||
|
async dropObjectTable(knex: Knex, objectApiName: string) {
|
||||||
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
|
await knex.schema.dropTableIfExists(tableName);
|
||||||
|
|
||||||
|
this.logger.log(`Dropped table: ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a field column to a table builder
|
||||||
|
*/
|
||||||
|
private addFieldColumn(
|
||||||
|
table: Knex.CreateTableBuilder | Knex.AlterTableBuilder,
|
||||||
|
field: FieldDefinition,
|
||||||
|
) {
|
||||||
|
const columnName = field.apiName;
|
||||||
|
|
||||||
|
let column: Knex.ColumnBuilder;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'String':
|
||||||
|
column = table.string(columnName, field.length || 255);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Text':
|
||||||
|
column = table.text(columnName);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Number':
|
||||||
|
if (field.scale && field.scale > 0) {
|
||||||
|
column = table.decimal(
|
||||||
|
columnName,
|
||||||
|
field.precision || 10,
|
||||||
|
field.scale,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
column = table.integer(columnName);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Boolean':
|
||||||
|
column = table.boolean(columnName).defaultTo(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Date':
|
||||||
|
column = table.date(columnName);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DateTime':
|
||||||
|
column = table.datetime(columnName);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Reference':
|
||||||
|
column = table.uuid(columnName);
|
||||||
|
if (field.referenceObject) {
|
||||||
|
const refTableName = this.getTableName(field.referenceObject);
|
||||||
|
column.references('id').inTable(refTableName).onDelete('SET NULL');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Email':
|
||||||
|
column = table.string(columnName, 255);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Phone':
|
||||||
|
column = table.string(columnName, 50);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Url':
|
||||||
|
column = table.string(columnName, 255);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Json':
|
||||||
|
column = table.json(columnName);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported field type: ${field.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isRequired) {
|
||||||
|
column.notNullable();
|
||||||
|
} else {
|
||||||
|
column.nullable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isUnique) {
|
||||||
|
column.unique();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.defaultValue) {
|
||||||
|
column.defaultTo(field.defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert object API name to table name (convert to snake_case, pluralize)
|
||||||
|
*/
|
||||||
|
private getTableName(apiName: string): string {
|
||||||
|
// Convert PascalCase to snake_case
|
||||||
|
const snakeCase = apiName
|
||||||
|
.replace(/([A-Z])/g, '_$1')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^_/, '');
|
||||||
|
|
||||||
|
// Simple pluralization (append 's' if not already plural)
|
||||||
|
// In production, use a proper pluralization library
|
||||||
|
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate field definition before creating column
|
||||||
|
*/
|
||||||
|
validateFieldDefinition(field: FieldDefinition) {
|
||||||
|
if (!field.apiName || !field.label || !field.type) {
|
||||||
|
throw new Error('Field must have apiName, label, and type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate field name (alphanumeric + underscore, starts with letter)
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(field.apiName)) {
|
||||||
|
throw new Error(`Invalid field name: ${field.apiName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reference field has referenceObject
|
||||||
|
if (field.type === 'Reference' && !field.referenceObject) {
|
||||||
|
throw new Error('Reference field must specify referenceObject');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate numeric fields
|
||||||
|
if (field.type === 'Number') {
|
||||||
|
if (field.scale && field.scale > 0 && !field.precision) {
|
||||||
|
throw new Error('Decimal fields must specify precision');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/prisma/central-prisma.service.ts
Normal file
16
backend/src/prisma/central-prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
|
||||||
|
|
||||||
|
let centralPrisma: CentralPrismaClient;
|
||||||
|
|
||||||
|
export function getCentralPrisma(): CentralPrismaClient {
|
||||||
|
if (!centralPrisma) {
|
||||||
|
centralPrisma = new CentralPrismaClient();
|
||||||
|
}
|
||||||
|
return centralPrisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectCentral() {
|
||||||
|
if (centralPrisma) {
|
||||||
|
await centralPrisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
124
backend/src/tenant/tenant-database.service.ts
Normal file
124
backend/src/tenant/tenant-database.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/src/tenant/tenant-provisioning.controller.ts
Normal file
36
backend/src/tenant/tenant-provisioning.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||||
|
|
||||||
|
@Controller('setup/tenants')
|
||||||
|
export class TenantProvisioningController {
|
||||||
|
constructor(
|
||||||
|
private readonly provisioningService: TenantProvisioningService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createTenant(
|
||||||
|
@Body()
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
primaryDomain: string;
|
||||||
|
dbHost?: string;
|
||||||
|
dbPort?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.provisioningService.provisionTenant(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':tenantId')
|
||||||
|
async deleteTenant(@Param('tenantId') tenantId: string) {
|
||||||
|
await this.provisioningService.deprovisionTenant(tenantId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
344
backend/src/tenant/tenant-provisioning.service.ts
Normal file
344
backend/src/tenant/tenant-provisioning.service.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
|
import * as knex from 'knex';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantProvisioningService {
|
||||||
|
private readonly logger = new Logger(TenantProvisioningService.name);
|
||||||
|
|
||||||
|
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision a new tenant with database and default data
|
||||||
|
*/
|
||||||
|
async provisionTenant(data: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
primaryDomain: string;
|
||||||
|
dbHost?: string;
|
||||||
|
dbPort?: number;
|
||||||
|
}) {
|
||||||
|
const dbHost = data.dbHost || process.env.DB_HOST || 'platform-db';
|
||||||
|
const dbPort = data.dbPort || parseInt(process.env.DB_PORT || '3306');
|
||||||
|
const dbName = `tenant_${data.slug}`;
|
||||||
|
const dbUsername = `tenant_${data.slug}_user`;
|
||||||
|
const dbPassword = this.generateSecurePassword();
|
||||||
|
|
||||||
|
this.logger.log(`Provisioning tenant: ${data.name} (${data.slug})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create MySQL database and user
|
||||||
|
await this.createTenantDatabase(
|
||||||
|
dbHost,
|
||||||
|
dbPort,
|
||||||
|
dbName,
|
||||||
|
dbUsername,
|
||||||
|
dbPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Run migrations on new tenant database
|
||||||
|
await this.runTenantMigrations(
|
||||||
|
dbHost,
|
||||||
|
dbPort,
|
||||||
|
dbName,
|
||||||
|
dbUsername,
|
||||||
|
dbPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Store tenant info in central database
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
const tenant = await centralPrisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
slug: data.slug,
|
||||||
|
dbHost,
|
||||||
|
dbPort,
|
||||||
|
dbName,
|
||||||
|
dbUsername,
|
||||||
|
dbPassword: this.tenantDbService.encryptPassword(dbPassword),
|
||||||
|
status: 'active',
|
||||||
|
domains: {
|
||||||
|
create: {
|
||||||
|
domain: data.primaryDomain,
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
domains: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Tenant provisioned successfully: ${tenant.id}`);
|
||||||
|
|
||||||
|
// Step 4: Seed default data (admin user, default roles, etc.)
|
||||||
|
await this.seedDefaultData(tenant.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
dbName,
|
||||||
|
dbUsername,
|
||||||
|
dbPassword, // Return for initial setup, should be stored securely
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to provision tenant: ${data.slug}`, error);
|
||||||
|
// Attempt cleanup
|
||||||
|
await this.rollbackProvisioning(dbHost, dbPort, dbName, dbUsername).catch(
|
||||||
|
(cleanupError) => {
|
||||||
|
this.logger.error(
|
||||||
|
'Failed to cleanup after provisioning error',
|
||||||
|
cleanupError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create MySQL database and user
|
||||||
|
*/
|
||||||
|
private async createTenantDatabase(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
dbName: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
// Connect as root to create database and user
|
||||||
|
const rootKnex = knex.default({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user: process.env.DB_ROOT_USER || 'root',
|
||||||
|
password: process.env.DB_ROOT_PASSWORD || 'root',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create database
|
||||||
|
await rootKnex.raw(
|
||||||
|
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
|
||||||
|
);
|
||||||
|
this.logger.log(`Database created: ${dbName}`);
|
||||||
|
|
||||||
|
// Create user and grant privileges
|
||||||
|
await rootKnex.raw(
|
||||||
|
`CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password}'`,
|
||||||
|
);
|
||||||
|
await rootKnex.raw(
|
||||||
|
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%'`,
|
||||||
|
);
|
||||||
|
await rootKnex.raw('FLUSH PRIVILEGES');
|
||||||
|
this.logger.log(`User created: ${username}`);
|
||||||
|
} finally {
|
||||||
|
await rootKnex.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Knex migrations on tenant database
|
||||||
|
*/
|
||||||
|
private async runTenantMigrations(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
dbName: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
const tenantKnex = knex.default({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
database: dbName,
|
||||||
|
user: username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
directory: './migrations/tenant',
|
||||||
|
tableName: 'knex_migrations',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tenantKnex.migrate.latest();
|
||||||
|
this.logger.log(`Migrations completed for database: ${dbName}`);
|
||||||
|
} finally {
|
||||||
|
await tenantKnex.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed default data for new tenant
|
||||||
|
*/
|
||||||
|
private async seedDefaultData(tenantId: string) {
|
||||||
|
const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create default roles
|
||||||
|
const adminRoleId = crypto.randomUUID();
|
||||||
|
await tenantKnex('roles').insert({
|
||||||
|
id: adminRoleId,
|
||||||
|
name: 'Admin',
|
||||||
|
guardName: 'api',
|
||||||
|
description: 'Full system administrator access',
|
||||||
|
created_at: tenantKnex.fn.now(),
|
||||||
|
updated_at: tenantKnex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRoleId = crypto.randomUUID();
|
||||||
|
await tenantKnex('roles').insert({
|
||||||
|
id: userRoleId,
|
||||||
|
name: 'User',
|
||||||
|
guardName: 'api',
|
||||||
|
description: 'Standard user access',
|
||||||
|
created_at: tenantKnex.fn.now(),
|
||||||
|
updated_at: tenantKnex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default permissions
|
||||||
|
const permissions = [
|
||||||
|
{ name: 'manage_users', description: 'Manage users' },
|
||||||
|
{ name: 'manage_roles', description: 'Manage roles and permissions' },
|
||||||
|
{ name: 'manage_apps', description: 'Manage applications' },
|
||||||
|
{ name: 'manage_objects', description: 'Manage object definitions' },
|
||||||
|
{ name: 'view_data', description: 'View data' },
|
||||||
|
{ name: 'create_data', description: 'Create data' },
|
||||||
|
{ name: 'edit_data', description: 'Edit data' },
|
||||||
|
{ name: 'delete_data', description: 'Delete data' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const perm of permissions) {
|
||||||
|
await tenantKnex('permissions').insert({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: perm.name,
|
||||||
|
guardName: 'api',
|
||||||
|
description: perm.description,
|
||||||
|
created_at: tenantKnex.fn.now(),
|
||||||
|
updated_at: tenantKnex.fn.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant all permissions to Admin role
|
||||||
|
const allPermissions = await tenantKnex('permissions').select('id');
|
||||||
|
for (const perm of allPermissions) {
|
||||||
|
await tenantKnex('role_permissions').insert({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
roleId: adminRoleId,
|
||||||
|
permissionId: perm.id,
|
||||||
|
created_at: tenantKnex.fn.now(),
|
||||||
|
updated_at: tenantKnex.fn.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant view/create/edit permissions to User role
|
||||||
|
const userPermissions = await tenantKnex('permissions')
|
||||||
|
.whereIn('name', ['view_data', 'create_data', 'edit_data'])
|
||||||
|
.select('id');
|
||||||
|
for (const perm of userPermissions) {
|
||||||
|
await tenantKnex('role_permissions').insert({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
roleId: userRoleId,
|
||||||
|
permissionId: perm.id,
|
||||||
|
created_at: tenantKnex.fn.now(),
|
||||||
|
updated_at: tenantKnex.fn.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Default data seeded for tenant: ${tenantId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to seed default data for tenant: ${tenantId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback provisioning in case of error
|
||||||
|
*/
|
||||||
|
private async rollbackProvisioning(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
dbName: string,
|
||||||
|
username: string,
|
||||||
|
) {
|
||||||
|
const rootKnex = knex.default({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user: process.env.DB_ROOT_USER || 'root',
|
||||||
|
password: process.env.DB_ROOT_PASSWORD || 'root',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rootKnex.raw(`DROP DATABASE IF EXISTS \`${dbName}\``);
|
||||||
|
await rootKnex.raw(`DROP USER IF EXISTS '${username}'@'%'`);
|
||||||
|
this.logger.log(`Rolled back provisioning for database: ${dbName}`);
|
||||||
|
} finally {
|
||||||
|
await rootKnex.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate secure random password
|
||||||
|
*/
|
||||||
|
private generateSecurePassword(): string {
|
||||||
|
return crypto.randomBytes(32).toString('base64').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deprovision a tenant (delete database and central record)
|
||||||
|
*/
|
||||||
|
async deprovisionTenant(tenantId: string) {
|
||||||
|
const centralPrisma = getCentralPrisma();
|
||||||
|
const tenant = await centralPrisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error(`Tenant not found: ${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete tenant database
|
||||||
|
const rootKnex = knex.default({
|
||||||
|
client: 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host: tenant.dbHost,
|
||||||
|
port: tenant.dbPort,
|
||||||
|
user: process.env.DB_ROOT_USER || 'root',
|
||||||
|
password: process.env.DB_ROOT_PASSWORD || 'root',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rootKnex.raw(`DROP DATABASE IF EXISTS \`${tenant.dbName}\``);
|
||||||
|
await rootKnex.raw(`DROP USER IF EXISTS '${tenant.dbUsername}'@'%'`);
|
||||||
|
this.logger.log(`Database deleted: ${tenant.dbName}`);
|
||||||
|
} finally {
|
||||||
|
await rootKnex.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete tenant from central database
|
||||||
|
await centralPrisma.tenant.delete({
|
||||||
|
where: { id: tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from connection cache
|
||||||
|
this.tenantDbService.removeTenantConnection(tenantId);
|
||||||
|
|
||||||
|
this.logger.log(`Tenant deprovisioned: ${tenantId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to deprovision tenant: ${tenantId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,64 @@
|
|||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { TenantDatabaseService } from './tenant-database.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
|
private readonly logger = new Logger(TenantMiddleware.name);
|
||||||
const tenantId = req.headers['x-tenant-id'] as string;
|
|
||||||
|
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) {
|
if (tenantId) {
|
||||||
// Attach tenantId to request object
|
// Attach tenant info to request object
|
||||||
(req as any).tenantId = tenantId;
|
(req as any).tenantId = tenantId;
|
||||||
|
if (subdomain) {
|
||||||
|
(req as any).subdomain = subdomain;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error in tenant middleware', error);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||||
import { TenantMiddleware } from './tenant.middleware';
|
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 {
|
export class TenantModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(TenantMiddleware).forRoutes('*');
|
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||||
|
|||||||
1
frontend/assets/images/pattern.svg
Normal file
1
frontend/assets/images/pattern.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 89 KiB |
57
frontend/components/AIChatBar.vue
Normal file
57
frontend/components/AIChatBar.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupTextarea,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
} from '@/components/ui/input-group'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { ArrowUp } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const chatInput = ref('')
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!chatInput.value.trim()) return
|
||||||
|
|
||||||
|
// TODO: Implement AI chat send functionality
|
||||||
|
console.log('Sending message:', chatInput.value)
|
||||||
|
chatInput.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-chat-area sticky bottom-0 z-20 bg-background border-t border-border p-4 bg-neutral-50">
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupTextarea
|
||||||
|
v-model="chatInput"
|
||||||
|
placeholder="Ask, Search or Chat..."
|
||||||
|
class="min-h-[60px] rounded-lg"
|
||||||
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<InputGroupText class="ml-auto">
|
||||||
|
52% used
|
||||||
|
</InputGroupText>
|
||||||
|
<Separator orientation="vertical" class="!h-4" />
|
||||||
|
<InputGroupButton
|
||||||
|
variant="default"
|
||||||
|
class="rounded-full"
|
||||||
|
:disabled="!chatInput.trim()"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<ArrowUp class="size-4" />
|
||||||
|
<span class="sr-only">Send</span>
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-chat-area {
|
||||||
|
height: calc(100vh / 6);
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -95,7 +95,7 @@ const menuItems = [
|
|||||||
<Collapsible v-else as-child :default-open="false" class="group/collapsible">
|
<Collapsible v-else as-child :default-open="false" class="group/collapsible">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapsibleTrigger as-child>
|
<CollapsibleTrigger as-child>
|
||||||
<SidebarMenuButton tooltip="{item.title}">
|
<SidebarMenuButton :tooltip="item.title">
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon" />
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
|
|||||||
19
frontend/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
frontend/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DropdownMenuRoot,
|
||||||
|
type DropdownMenuRootEmits,
|
||||||
|
type DropdownMenuRootProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuRootProps>()
|
||||||
|
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
</template>
|
||||||
26
frontend/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
26
frontend/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownMenuPortal, DropdownMenuContent, type DropdownMenuContentProps, type DropdownMenuContentEmits, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(), {
|
||||||
|
sideOffset: 4,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
props.class
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
22
frontend/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuItemProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(props)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
props.class
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</template>
|
||||||
11
frontend/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
11
frontend/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</template>
|
||||||
4
frontend/components/ui/dropdown-menu/index.ts
Normal file
4
frontend/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as DropdownMenu } from './DropdownMenu.vue'
|
||||||
|
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
|
||||||
|
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
|
||||||
|
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
|
||||||
14
frontend/components/ui/input-group/InputGroup.vue
Normal file
14
frontend/components/ui/input-group/InputGroup.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('relative flex w-full flex-col gap-2', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/input-group/InputGroupAddon.vue
Normal file
22
frontend/components/ui/input-group/InputGroupAddon.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
align?: 'start' | 'end' | 'center' | 'block-end'
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const alignClasses = {
|
||||||
|
start: 'justify-start',
|
||||||
|
end: 'justify-end',
|
||||||
|
center: 'justify-center',
|
||||||
|
'block-end': 'justify-end items-end',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex flex-wrap items-center gap-2', alignClasses[props.align || 'start'], props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
frontend/components/ui/input-group/InputGroupButton.vue
Normal file
28
frontend/components/ui/input-group/InputGroupButton.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ButtonVariants } from '../button'
|
||||||
|
import { Button } from '../button'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:variant="props.variant"
|
||||||
|
:size="props.size"
|
||||||
|
:class="props.class"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/input-group/InputGroupText.vue
Normal file
14
frontend/components/ui/input-group/InputGroupText.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('text-sm text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/input-group/InputGroupTextarea.vue
Normal file
22
frontend/components/ui/input-group/InputGroupTextarea.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const model = defineModel<string>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
v-model="model"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
:class="cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none',
|
||||||
|
props.class
|
||||||
|
)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
5
frontend/components/ui/input-group/index.ts
Normal file
5
frontend/components/ui/input-group/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as InputGroup } from './InputGroup.vue'
|
||||||
|
export { default as InputGroupTextarea } from './InputGroupTextarea.vue'
|
||||||
|
export { default as InputGroupAddon } from './InputGroupAddon.vue'
|
||||||
|
export { default as InputGroupButton } from './InputGroupButton.vue'
|
||||||
|
export { default as InputGroupText } from './InputGroupText.vue'
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
export const useApi = () => {
|
export const useApi = () => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const apiBaseUrl = config.public.apiBaseUrl
|
|
||||||
|
// Use current domain for API calls (same subdomain routing)
|
||||||
|
const getApiBaseUrl = () => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
// In browser, use current hostname but with port 3000 for API
|
||||||
|
const currentHost = window.location.hostname
|
||||||
|
const protocol = window.location.protocol
|
||||||
|
return `${protocol}//${currentHost}:3000`
|
||||||
|
}
|
||||||
|
// Fallback for SSR
|
||||||
|
return config.public.apiBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
const getHeaders = () => {
|
const getHeaders = () => {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -25,7 +36,7 @@ export const useApi = () => {
|
|||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
async get(path: string) {
|
async get(path: string) {
|
||||||
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
@@ -33,7 +44,7 @@ export const useApi = () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async post(path: string, data: any) {
|
async post(path: string, data: any) {
|
||||||
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -43,7 +54,7 @@ export const useApi = () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async put(path: string, data: any) {
|
async put(path: string, data: any) {
|
||||||
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -53,7 +64,7 @@ export const useApi = () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async delete(path: string) {
|
async delete(path: string) {
|
||||||
const response = await fetch(`${apiBaseUrl}/api${path}`, {
|
const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppSidebar from '@/components/AppSidebar.vue'
|
import AppSidebar from '@/components/AppSidebar.vue'
|
||||||
|
import AIChatBar from '@/components/AIChatBar.vue'
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -26,9 +27,9 @@ const breadcrumbs = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset class="flex flex-col">
|
||||||
<header
|
<header
|
||||||
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b"
|
class="relative z-10 flex h-16 shrink-0 items-center gap-2 bg-background transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 border-b shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 px-4">
|
<div class="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger class="-ml-1" />
|
<SidebarTrigger class="-ml-1" />
|
||||||
@@ -58,9 +59,60 @@ const breadcrumbs = computed(() => {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-1 flex-col gap-4 p-4 pt-0">
|
|
||||||
|
<!-- Main scrollable content -->
|
||||||
|
<div class="layout-slot-container flex flex-1 flex-col gap-4 p-4 pt-0 overflow-y-auto">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Chat Bar Component -->
|
||||||
|
<AIChatBar />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-slot-container {
|
||||||
|
position: relative;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: linear-gradient(to bottom, #DFDBE5 0%, rgba(223, 219, 229, 0) 100%);
|
||||||
|
background-size: 100% 150px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-slot-container::before,
|
||||||
|
.layout-slot-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(to bottom, rgba(156, 146, 172, 0.55) 0%, rgba(156, 146, 172, 0) 100%);
|
||||||
|
-webkit-mask-image: url("~/assets/images/pattern.svg");
|
||||||
|
-webkit-mask-repeat: repeat;
|
||||||
|
-webkit-mask-size: 600px 600px;
|
||||||
|
mask-image: url("~/assets/images/pattern.svg");
|
||||||
|
mask-repeat: repeat;
|
||||||
|
mask-size: 600px 600px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crisp pattern that fades out */
|
||||||
|
.layout-slot-container::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slightly shifted, blurred layer to create a “blur into white” effect */
|
||||||
|
.layout-slot-container::after {
|
||||||
|
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-slot-container > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
v-for="app in apps"
|
v-for="app in apps"
|
||||||
:key="app.id"
|
:key="app.id"
|
||||||
:to="`/setup/apps/${app.slug}`"
|
:to="`/setup/apps/${app.slug}`"
|
||||||
class="p-6 border rounded-lg hover:border-primary transition-colors bg-card"
|
class="p-6 border rounded-lg hover:border-primary transition-colors bg-card bg-background shadow-md"
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-semibold mb-2">{{ app.label }}</h3>
|
<h3 class="text-xl font-semibold mb-2">{{ app.label }}</h3>
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
context: ../backend
|
context: ../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: platform-api
|
container_name: platform-api
|
||||||
command: npm run start:dev
|
command: npm run start:dev -- --host 0.0.0.0
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env.api
|
- ../.env.api
|
||||||
ports:
|
ports:
|
||||||
@@ -43,14 +43,14 @@ services:
|
|||||||
container_name: platform-db
|
container_name: platform-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: root
|
MYSQL_ROOT_PASSWORD: "asjdnfqTash37faggT"
|
||||||
MYSQL_DATABASE: platform
|
MYSQL_DATABASE: platform
|
||||||
MYSQL_USER: platform
|
MYSQL_USER: platform
|
||||||
MYSQL_PASSWORD: platform
|
MYSQL_PASSWORD: platform
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
##volumes:
|
||||||
- percona-data:/var/lib/mysql
|
##- percona-data:/var/lib/mysql
|
||||||
networks:
|
networks:
|
||||||
- platform-network
|
- platform-network
|
||||||
|
|
||||||
|
|||||||
120
test-multi-tenant.sh
Executable file
120
test-multi-tenant.sh
Executable 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>"
|
||||||
Reference in New Issue
Block a user