WIP - work with miagrations and objects set up
This commit is contained in:
91
backend/MIGRATION_QUICK_REFERENCE.txt
Normal file
91
backend/MIGRATION_QUICK_REFERENCE.txt
Normal file
@@ -0,0 +1,91 @@
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ TENANT MIGRATION - QUICK REFERENCE ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
📍 LOCATION: /root/neo/backend
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ COMMON COMMANDS │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Create Migration:
|
||||
$ npm run migrate:make add_my_feature
|
||||
|
||||
Check Status:
|
||||
$ npm run migrate:status
|
||||
|
||||
Test on One Tenant:
|
||||
$ npm run migrate:tenant acme-corp
|
||||
|
||||
Apply to All Tenants:
|
||||
$ npm run migrate:all-tenants
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ALL AVAILABLE COMMANDS │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
npm run migrate:make <name> Create new migration file
|
||||
npm run migrate:status Check status across all tenants
|
||||
npm run migrate:tenant <slug> Migrate specific tenant
|
||||
npm run migrate:all-tenants Migrate all active tenants
|
||||
npm run migrate:latest Migrate default DB (rarely used)
|
||||
npm run migrate:rollback Rollback default DB (rarely used)
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ TYPICAL WORKFLOW │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. Create: npm run migrate:make add_priority_field
|
||||
2. Edit: vim migrations/tenant/20250127_*.js
|
||||
3. Test: npm run migrate:tenant test-company
|
||||
4. Status: npm run migrate:status
|
||||
5. Deploy: npm run migrate:all-tenants
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ENVIRONMENT REQUIRED │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
export DB_ENCRYPTION_KEY="your-32-character-secret-key!!"
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ FILE LOCATIONS │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Scripts: backend/scripts/migrate-*.ts
|
||||
Migrations: backend/migrations/tenant/
|
||||
Config: backend/knexfile.js
|
||||
Docs: TENANT_MIGRATION_GUIDE.md
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ DOCUMENTATION │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Quick Guide: cat TENANT_MIGRATION_GUIDE.md
|
||||
Script Docs: cat backend/scripts/README.md
|
||||
Complete: cat TENANT_MIGRATION_IMPLEMENTATION_COMPLETE.md
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ TROUBLESHOOTING │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Missing Prisma Client:
|
||||
$ npx prisma generate --schema=prisma/schema-central.prisma
|
||||
|
||||
Check Scripts Available:
|
||||
$ npm run | grep migrate
|
||||
|
||||
Connection Error:
|
||||
- Check DB_ENCRYPTION_KEY matches encryption key
|
||||
- Verify central database is accessible
|
||||
- Ensure tenant databases are online
|
||||
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ For detailed help: cat TENANT_MIGRATION_GUIDE.md ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
@@ -17,7 +17,13 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"migrate:make": "knex migrate:make --knexfile=knexfile.js",
|
||||
"migrate:latest": "knex migrate:latest --knexfile=knexfile.js",
|
||||
"migrate:rollback": "knex migrate:rollback --knexfile=knexfile.js",
|
||||
"migrate:status": "ts-node -r tsconfig-paths/register scripts/check-migration-status.ts",
|
||||
"migrate:tenant": "ts-node -r tsconfig-paths/register scripts/migrate-tenant.ts",
|
||||
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^10.1.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Tenant-specific database schema
|
||||
// This schema is applied to each tenant's database
|
||||
// NOTE: Each tenant has its own database, so there is NO tenantId column in these tables
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
@@ -11,30 +12,10 @@ datasource db {
|
||||
url = env("TENANT_DATABASE_URL")
|
||||
}
|
||||
|
||||
// Multi-tenancy
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
objectDefinitions ObjectDefinition[]
|
||||
accounts Account[]
|
||||
apps App[]
|
||||
roles Role[]
|
||||
permissions Permission[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
// User & Auth
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
email String
|
||||
email String @unique
|
||||
password String
|
||||
firstName String?
|
||||
lastName String?
|
||||
@@ -42,48 +23,39 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userRoles UserRole[]
|
||||
accounts Account[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// RBAC - Spatie-like
|
||||
model Role {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
guardName String @default("api")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
userRoles UserRole[]
|
||||
rolePermissions RolePermission[]
|
||||
|
||||
@@unique([tenantId, name, guardName])
|
||||
@@index([tenantId])
|
||||
@@unique([name, guardName])
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
guardName String @default("api")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
rolePermissions RolePermission[]
|
||||
|
||||
@@unique([tenantId, name, guardName])
|
||||
@@index([tenantId])
|
||||
@@unique([name, guardName])
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
@@ -120,66 +92,59 @@ model RolePermission {
|
||||
// Object Definition (Metadata)
|
||||
model ObjectDefinition {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
apiName String
|
||||
apiName String @unique
|
||||
label String
|
||||
pluralLabel String?
|
||||
description String? @db.Text
|
||||
isSystem Boolean @default(false)
|
||||
tableName String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isCustom Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
fields FieldDefinition[]
|
||||
pages AppPage[]
|
||||
|
||||
@@unique([tenantId, apiName])
|
||||
@@index([tenantId])
|
||||
@@map("object_definitions")
|
||||
}
|
||||
|
||||
model FieldDefinition {
|
||||
id String @id @default(uuid())
|
||||
objectId String
|
||||
apiName String
|
||||
label String
|
||||
type String // text, number, boolean, date, datetime, lookup, picklist, etc.
|
||||
description String? @db.Text
|
||||
isRequired Boolean @default(false)
|
||||
isUnique Boolean @default(false)
|
||||
isReadonly Boolean @default(false)
|
||||
isLookup Boolean @default(false)
|
||||
referenceTo String? // objectApiName for lookup fields
|
||||
defaultValue String?
|
||||
options Json? // for picklist fields
|
||||
validationRules Json? // custom validation rules
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
objectDefinitionId String
|
||||
apiName String
|
||||
label String
|
||||
type String // String, Number, Date, Boolean, Reference, etc.
|
||||
length Int?
|
||||
precision Int?
|
||||
scale Int?
|
||||
referenceObject String?
|
||||
defaultValue String? @db.Text
|
||||
description String? @db.Text
|
||||
isRequired Boolean @default(false)
|
||||
isUnique Boolean @default(false)
|
||||
isSystem Boolean @default(false)
|
||||
isCustom Boolean @default(true)
|
||||
displayOrder Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
object ObjectDefinition @relation(fields: [objectId], references: [id], onDelete: Cascade)
|
||||
object ObjectDefinition @relation(fields: [objectDefinitionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([objectId, apiName])
|
||||
@@index([objectId])
|
||||
@@unique([objectDefinitionId, apiName])
|
||||
@@index([objectDefinitionId])
|
||||
@@map("field_definitions")
|
||||
}
|
||||
|
||||
// Example static object: Account
|
||||
model Account {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
status String @default("active")
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([ownerId])
|
||||
@@map("accounts")
|
||||
}
|
||||
@@ -187,8 +152,7 @@ model Account {
|
||||
// Application Builder
|
||||
model App {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
slug String
|
||||
slug String @unique
|
||||
label String
|
||||
description String? @db.Text
|
||||
icon String?
|
||||
@@ -196,11 +160,8 @@ model App {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
pages AppPage[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
@@map("apps")
|
||||
}
|
||||
|
||||
|
||||
194
backend/scripts/README.md
Normal file
194
backend/scripts/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Tenant Migration Scripts
|
||||
|
||||
This directory contains scripts for managing database migrations across all tenants in the multi-tenant platform.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
### 1. Create a New Migration
|
||||
|
||||
```bash
|
||||
npm run migrate:make <migration_name>
|
||||
```
|
||||
|
||||
Creates a new migration file in `migrations/tenant/` directory.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npm run migrate:make add_status_field_to_contacts
|
||||
```
|
||||
|
||||
### 2. Migrate a Single Tenant
|
||||
|
||||
```bash
|
||||
npm run migrate:tenant <tenant-slug-or-id>
|
||||
```
|
||||
|
||||
Runs all pending migrations for a specific tenant. You can identify the tenant by its slug or ID.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npm run migrate:tenant acme-corp
|
||||
npm run migrate:tenant cm5a1b2c3d4e5f6g7h8i9j0k
|
||||
```
|
||||
|
||||
### 3. Migrate All Tenants
|
||||
|
||||
```bash
|
||||
npm run migrate:all-tenants
|
||||
```
|
||||
|
||||
Runs all pending migrations for **all active tenants** in the system. This is useful when:
|
||||
- You've created a new migration that needs to be applied to all tenants
|
||||
- You're updating the schema across the entire platform
|
||||
- You need to ensure all tenants are up to date
|
||||
|
||||
**Output:**
|
||||
- Shows progress for each tenant
|
||||
- Lists which migrations were applied
|
||||
- Provides a summary at the end
|
||||
- Exits with error code if any tenant fails
|
||||
|
||||
### 4. Rollback Migration (Manual)
|
||||
|
||||
```bash
|
||||
npm run migrate:rollback
|
||||
```
|
||||
|
||||
⚠️ **Warning:** This runs a rollback on the **default database** configured in `knexfile.js`. For tenant-specific rollbacks, you'll need to manually configure the connection.
|
||||
|
||||
## Migration Flow
|
||||
|
||||
### During New Tenant Provisioning
|
||||
|
||||
When a new tenant is created via the API, migrations are automatically run as part of the provisioning process:
|
||||
|
||||
1. Tenant database is created
|
||||
2. `TenantProvisioningService.runTenantMigrations()` is called
|
||||
3. All migrations in `migrations/tenant/` are executed
|
||||
|
||||
### For Existing Tenants
|
||||
|
||||
When you add a new migration file and need to apply it to existing tenants:
|
||||
|
||||
1. Create the migration:
|
||||
```bash
|
||||
npm run migrate:make add_new_feature
|
||||
```
|
||||
|
||||
2. Edit the generated migration file in `migrations/tenant/`
|
||||
|
||||
3. Test on a single tenant first:
|
||||
```bash
|
||||
npm run migrate:tenant test-tenant
|
||||
```
|
||||
|
||||
4. If successful, apply to all tenants:
|
||||
```bash
|
||||
npm run migrate:all-tenants
|
||||
```
|
||||
|
||||
## Migration Directory Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── migrations/
|
||||
│ └── tenant/ # Tenant-specific migrations
|
||||
│ ├── 20250126000001_create_users_and_rbac.js
|
||||
│ ├── 20250126000002_create_object_definitions.js
|
||||
│ └── ...
|
||||
├── scripts/
|
||||
│ ├── migrate-tenant.ts # Single tenant migration
|
||||
│ └── migrate-all-tenants.ts # All tenants migration
|
||||
└── knexfile.js # Knex configuration
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
### Database Password Encryption
|
||||
|
||||
Tenant database passwords are encrypted in the central database using AES-256-CBC encryption. The migration scripts automatically:
|
||||
|
||||
1. Fetch tenant connection details from the central database
|
||||
2. Decrypt the database password using the `DB_ENCRYPTION_KEY` environment variable
|
||||
3. Connect to the tenant database
|
||||
4. Run migrations
|
||||
5. Close the connection
|
||||
|
||||
**Required Environment Variable:**
|
||||
```bash
|
||||
DB_ENCRYPTION_KEY=your-32-character-secret-key!!
|
||||
```
|
||||
|
||||
This key must match the key used by `TenantService` for encryption.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration Fails for One Tenant
|
||||
|
||||
If `migrate:all-tenants` fails for a specific tenant:
|
||||
|
||||
1. Check the error message in the output
|
||||
2. Investigate the tenant's database directly
|
||||
3. Fix the issue (manual SQL, data cleanup, etc.)
|
||||
4. Re-run migrations for that tenant: `npm run migrate:tenant <slug>`
|
||||
5. Once fixed, run `migrate:all-tenants` again to ensure others are updated
|
||||
|
||||
### Migration Already Exists
|
||||
|
||||
Knex tracks which migrations have been run in the `knex_migrations` table in each tenant database. If a migration was already applied, it will be skipped automatically.
|
||||
|
||||
### Connection Issues
|
||||
|
||||
If you see connection errors:
|
||||
|
||||
1. Verify the central database is accessible
|
||||
2. Check that tenant database credentials are correct
|
||||
3. Ensure `DB_ENCRYPTION_KEY` matches the one used for encryption
|
||||
4. Verify the tenant's database server is running and accessible
|
||||
|
||||
## Example Migration File
|
||||
|
||||
```javascript
|
||||
// migrations/tenant/20250126000006_add_custom_fields.js
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await knex.schema.table('field_definitions', (table) => {
|
||||
table.boolean('is_custom').defaultTo(false);
|
||||
table.string('custom_type', 50).nullable();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.table('field_definitions', (table) => {
|
||||
table.dropColumn('is_custom');
|
||||
table.dropColumn('custom_type');
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always test on a single tenant first** before running migrations on all tenants
|
||||
2. **Include rollback logic** in your `down()` function
|
||||
3. **Use transactions** for complex multi-step migrations
|
||||
4. **Backup production databases** before running migrations
|
||||
5. **Monitor the output** when running `migrate:all-tenants` to catch any failures
|
||||
6. **Version control** your migration files
|
||||
7. **Document breaking changes** in migration comments
|
||||
8. **Consider data migrations** separately from schema migrations when dealing with large datasets
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
In your deployment pipeline, you can automatically migrate all tenants:
|
||||
|
||||
```bash
|
||||
# After deploying new code
|
||||
npm run migrate:all-tenants
|
||||
```
|
||||
|
||||
Or integrate it into your Docker deployment:
|
||||
|
||||
```dockerfile
|
||||
# In your Dockerfile or docker-compose.yml
|
||||
CMD npm run migrate:all-tenants && npm run start:prod
|
||||
```
|
||||
181
backend/scripts/check-migration-status.ts
Normal file
181
backend/scripts/check-migration-status.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
|
||||
import knex, { Knex } from 'knex';
|
||||
import { createDecipheriv } from 'crypto';
|
||||
|
||||
// Encryption configuration
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
/**
|
||||
* Decrypt a tenant's database password
|
||||
*/
|
||||
function decryptPassword(encryptedPassword: string): string {
|
||||
try {
|
||||
// Check if password is already plaintext (for legacy/development)
|
||||
if (!encryptedPassword.includes(':')) {
|
||||
return encryptedPassword;
|
||||
}
|
||||
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
const parts = encryptedPassword.split(':');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid encrypted password format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('Error decrypting password:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Knex connection for a specific tenant
|
||||
*/
|
||||
function createTenantKnexConnection(tenant: any): Knex {
|
||||
const decryptedPassword = decryptPassword(tenant.dbPassword);
|
||||
|
||||
return knex({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: tenant.dbHost,
|
||||
port: tenant.dbPort,
|
||||
user: tenant.dbUsername,
|
||||
password: decryptedPassword,
|
||||
database: tenant.dbName,
|
||||
},
|
||||
migrations: {
|
||||
tableName: 'knex_migrations',
|
||||
directory: './migrations/tenant',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get migration status for a specific tenant
|
||||
*/
|
||||
async function getTenantMigrationStatus(tenant: any): Promise<{
|
||||
completed: string[];
|
||||
pending: string[];
|
||||
}> {
|
||||
const tenantKnex = createTenantKnexConnection(tenant);
|
||||
|
||||
try {
|
||||
const [completed, pending] = await tenantKnex.migrate.list();
|
||||
return {
|
||||
completed: completed[1] || [],
|
||||
pending: pending || [],
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
await tenantKnex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check migration status across all tenants
|
||||
*/
|
||||
async function checkMigrationStatus() {
|
||||
console.log('🔍 Checking migration status for all tenants...\n');
|
||||
|
||||
const centralPrisma = new CentralPrismaClient();
|
||||
|
||||
try {
|
||||
// Fetch all active tenants
|
||||
const tenants = await centralPrisma.tenant.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (tenants.length === 0) {
|
||||
console.log('⚠️ No active tenants found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${tenants.length} active tenant(s)\n`);
|
||||
console.log('='.repeat(80));
|
||||
|
||||
let allUpToDate = true;
|
||||
const tenantsWithPending: { name: string; pending: string[] }[] = [];
|
||||
|
||||
// Check each tenant
|
||||
for (const tenant of tenants) {
|
||||
try {
|
||||
const status = await getTenantMigrationStatus(tenant);
|
||||
|
||||
console.log(`\n📦 ${tenant.name} (${tenant.slug})`);
|
||||
console.log(` Database: ${tenant.dbName}`);
|
||||
console.log(` Completed: ${status.completed.length} migration(s)`);
|
||||
|
||||
if (status.pending.length > 0) {
|
||||
allUpToDate = false;
|
||||
console.log(` ⚠️ Pending: ${status.pending.length} migration(s)`);
|
||||
status.pending.forEach((migration) => {
|
||||
console.log(` - ${migration}`);
|
||||
});
|
||||
tenantsWithPending.push({
|
||||
name: tenant.name,
|
||||
pending: status.pending,
|
||||
});
|
||||
} else {
|
||||
console.log(` ✅ Up to date`);
|
||||
}
|
||||
|
||||
// Show last 3 completed migrations
|
||||
if (status.completed.length > 0) {
|
||||
const recent = status.completed.slice(-3);
|
||||
console.log(` Recent migrations:`);
|
||||
recent.forEach((migration) => {
|
||||
console.log(` - ${migration}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`\n❌ ${tenant.name}: Failed to check status`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
allUpToDate = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('📊 Summary');
|
||||
console.log('='.repeat(80));
|
||||
|
||||
if (allUpToDate) {
|
||||
console.log('✅ All tenants are up to date!');
|
||||
} else {
|
||||
console.log(`⚠️ ${tenantsWithPending.length} tenant(s) have pending migrations:\n`);
|
||||
tenantsWithPending.forEach(({ name, pending }) => {
|
||||
console.log(` ${name}: ${pending.length} pending`);
|
||||
});
|
||||
console.log('\n💡 Run: npm run migrate:all-tenants');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await centralPrisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the status check
|
||||
checkMigrationStatus()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
165
backend/scripts/migrate-all-tenants.ts
Normal file
165
backend/scripts/migrate-all-tenants.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
|
||||
import knex, { Knex } from 'knex';
|
||||
import { createDecipheriv } from 'crypto';
|
||||
|
||||
// Encryption configuration - must match the one used in tenant service
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
/**
|
||||
* Decrypt a tenant's database password
|
||||
*/
|
||||
function decryptPassword(encryptedPassword: string): string {
|
||||
try {
|
||||
// Check if password is already plaintext (for legacy/development)
|
||||
if (!encryptedPassword.includes(':')) {
|
||||
console.warn('⚠️ Password appears to be unencrypted, using as-is');
|
||||
return encryptedPassword;
|
||||
}
|
||||
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
const parts = encryptedPassword.split(':');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid encrypted password format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('Error decrypting password:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Knex connection for a specific tenant
|
||||
*/
|
||||
function createTenantKnexConnection(tenant: any): Knex {
|
||||
const decryptedPassword = decryptPassword(tenant.dbPassword);
|
||||
|
||||
return knex({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: tenant.dbHost,
|
||||
port: tenant.dbPort,
|
||||
user: tenant.dbUsername,
|
||||
password: decryptedPassword,
|
||||
database: tenant.dbName,
|
||||
},
|
||||
migrations: {
|
||||
tableName: 'knex_migrations',
|
||||
directory: './migrations/tenant',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations for a specific tenant
|
||||
*/
|
||||
async function migrateTenant(tenant: any): Promise<void> {
|
||||
console.log(`\n🔄 Migrating tenant: ${tenant.name} (${tenant.dbName})`);
|
||||
|
||||
const tenantKnex = createTenantKnexConnection(tenant);
|
||||
|
||||
try {
|
||||
const [batchNo, log] = await tenantKnex.migrate.latest();
|
||||
|
||||
if (log.length === 0) {
|
||||
console.log(`✅ ${tenant.name}: Already up to date`);
|
||||
} else {
|
||||
console.log(`✅ ${tenant.name}: Ran ${log.length} migrations:`);
|
||||
log.forEach((migration) => {
|
||||
console.log(` - ${migration}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ ${tenant.name}: Migration failed:`, error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await tenantKnex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to migrate all active tenants
|
||||
*/
|
||||
async function migrateAllTenants() {
|
||||
console.log('🚀 Starting migration for all tenants...\n');
|
||||
|
||||
const centralPrisma = new CentralPrismaClient();
|
||||
|
||||
try {
|
||||
// Fetch all active tenants
|
||||
const tenants = await centralPrisma.tenant.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (tenants.length === 0) {
|
||||
console.log('⚠️ No active tenants found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${tenants.length} active tenant(s)\n`);
|
||||
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
const failures: { tenant: string; error: string }[] = [];
|
||||
|
||||
// Migrate each tenant sequentially
|
||||
for (const tenant of tenants) {
|
||||
try {
|
||||
await migrateTenant(tenant);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
failureCount++;
|
||||
failures.push({
|
||||
tenant: tenant.name,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 Migration Summary');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Successful: ${successCount}`);
|
||||
console.log(`❌ Failed: ${failureCount}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ Failed Tenants:');
|
||||
failures.forEach(({ tenant, error }) => {
|
||||
console.log(` - ${tenant}: ${error}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n🎉 All tenant migrations completed successfully!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await centralPrisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the migration
|
||||
migrateAllTenants()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
134
backend/scripts/migrate-tenant.ts
Normal file
134
backend/scripts/migrate-tenant.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
|
||||
import knex, { Knex } from 'knex';
|
||||
import { createDecipheriv } from 'crypto';
|
||||
|
||||
// Encryption configuration
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
/**
|
||||
* Decrypt a tenant's database password
|
||||
*/
|
||||
function decryptPassword(encryptedPassword: string): string {
|
||||
try {
|
||||
// Check if password is already plaintext (for legacy/development)
|
||||
if (!encryptedPassword.includes(':')) {
|
||||
console.warn('⚠️ Password appears to be unencrypted, using as-is');
|
||||
return encryptedPassword;
|
||||
}
|
||||
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
const parts = encryptedPassword.split(':');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid encrypted password format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('Error decrypting password:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Knex connection for a specific tenant
|
||||
*/
|
||||
function createTenantKnexConnection(tenant: any): Knex {
|
||||
const decryptedPassword = decryptPassword(tenant.dbPassword);
|
||||
|
||||
return knex({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: tenant.dbHost,
|
||||
port: tenant.dbPort,
|
||||
user: tenant.dbUsername,
|
||||
password: decryptedPassword,
|
||||
database: tenant.dbName,
|
||||
},
|
||||
migrations: {
|
||||
tableName: 'knex_migrations',
|
||||
directory: './migrations/tenant',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a specific tenant by slug or ID
|
||||
*/
|
||||
async function migrateTenant() {
|
||||
const tenantIdentifier = process.argv[2];
|
||||
|
||||
if (!tenantIdentifier) {
|
||||
console.error('❌ Usage: npm run migrate:tenant <tenant-slug-or-id>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔍 Looking for tenant: ${tenantIdentifier}\n`);
|
||||
|
||||
const centralPrisma = new CentralPrismaClient();
|
||||
|
||||
try {
|
||||
// Find tenant by slug or ID
|
||||
const tenant = await centralPrisma.tenant.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ slug: tenantIdentifier },
|
||||
{ id: tenantIdentifier },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`❌ Tenant not found: ${tenantIdentifier}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📋 Tenant: ${tenant.name} (${tenant.slug})`);
|
||||
console.log(`📊 Database: ${tenant.dbName}`);
|
||||
console.log(`🔄 Running migrations...\n`);
|
||||
|
||||
const tenantKnex = createTenantKnexConnection(tenant);
|
||||
|
||||
try {
|
||||
const [batchNo, log] = await tenantKnex.migrate.latest();
|
||||
|
||||
if (log.length === 0) {
|
||||
console.log(`✅ Already up to date (batch ${batchNo})`);
|
||||
} else {
|
||||
console.log(`✅ Ran ${log.length} migration(s) (batch ${batchNo}):`);
|
||||
log.forEach((migration) => {
|
||||
console.log(` - ${migration}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n🎉 Migration completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await tenantKnex.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await centralPrisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the migration
|
||||
migrateTenant()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
147
backend/seeds/example_account_fields_with_ui_metadata.js
Normal file
147
backend/seeds/example_account_fields_with_ui_metadata.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Example seed data for Account object with UI metadata
|
||||
* Run this after migrations to add UI metadata to existing Account fields
|
||||
*/
|
||||
|
||||
exports.seed = async function(knex) {
|
||||
// Get the Account object
|
||||
const accountObj = await knex('object_definitions')
|
||||
.where({ apiName: 'Account' })
|
||||
.first();
|
||||
|
||||
if (!accountObj) {
|
||||
console.log('Account object not found. Please run migrations first.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found Account object with ID: ${accountObj.id}`);
|
||||
|
||||
// Update existing Account fields with UI metadata
|
||||
const fieldsToUpdate = [
|
||||
{
|
||||
apiName: 'name',
|
||||
ui_metadata: JSON.stringify({
|
||||
fieldType: 'TEXT',
|
||||
placeholder: 'Enter account name',
|
||||
helpText: 'The name of the organization or company',
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
section: 'basic',
|
||||
sectionLabel: 'Basic Information',
|
||||
sectionOrder: 1,
|
||||
validationRules: [
|
||||
{ type: 'required', message: 'Account name is required' },
|
||||
{ type: 'minLength', value: 2, message: 'Account name must be at least 2 characters' },
|
||||
{ type: 'maxLength', value: 255, message: 'Account name cannot exceed 255 characters' }
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
apiName: 'website',
|
||||
ui_metadata: JSON.stringify({
|
||||
fieldType: 'URL',
|
||||
placeholder: 'https://www.example.com',
|
||||
helpText: 'Company website URL',
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
section: 'basic',
|
||||
sectionLabel: 'Basic Information',
|
||||
sectionOrder: 1,
|
||||
validationRules: [
|
||||
{ type: 'url', message: 'Please enter a valid URL' }
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
apiName: 'phone',
|
||||
ui_metadata: JSON.stringify({
|
||||
fieldType: 'TEXT',
|
||||
placeholder: '+1 (555) 000-0000',
|
||||
helpText: 'Primary phone number',
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: false,
|
||||
section: 'contact',
|
||||
sectionLabel: 'Contact Information',
|
||||
sectionOrder: 2,
|
||||
validationRules: [
|
||||
{ type: 'pattern', value: '^\\+?[0-9\\s\\-\\(\\)]+$', message: 'Please enter a valid phone number' }
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
apiName: 'industry',
|
||||
ui_metadata: JSON.stringify({
|
||||
fieldType: 'SELECT',
|
||||
placeholder: 'Select industry',
|
||||
helpText: 'The primary industry this account operates in',
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
section: 'details',
|
||||
sectionLabel: 'Account Details',
|
||||
sectionOrder: 3,
|
||||
options: [
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
{ value: 'finance', label: 'Finance' },
|
||||
{ value: 'healthcare', label: 'Healthcare' },
|
||||
{ value: 'manufacturing', label: 'Manufacturing' },
|
||||
{ value: 'retail', label: 'Retail' },
|
||||
{ value: 'education', label: 'Education' },
|
||||
{ value: 'government', label: 'Government' },
|
||||
{ value: 'nonprofit', label: 'Non-Profit' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
]
|
||||
})
|
||||
},
|
||||
{
|
||||
apiName: 'ownerId',
|
||||
ui_metadata: JSON.stringify({
|
||||
fieldType: 'SELECT',
|
||||
placeholder: 'Select owner',
|
||||
helpText: 'The user who owns this account',
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
section: 'system',
|
||||
sectionLabel: 'System Information',
|
||||
sectionOrder: 4,
|
||||
// This would be dynamically populated from the users table
|
||||
// For now, providing static structure
|
||||
isReference: true,
|
||||
referenceObject: 'User',
|
||||
referenceDisplayField: 'name'
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
// Update each field with UI metadata
|
||||
for (const fieldUpdate of fieldsToUpdate) {
|
||||
const result = await knex('field_definitions')
|
||||
.where({
|
||||
objectDefinitionId: accountObj.id,
|
||||
apiName: fieldUpdate.apiName
|
||||
})
|
||||
.update({
|
||||
ui_metadata: fieldUpdate.ui_metadata,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
|
||||
if (result > 0) {
|
||||
console.log(`✓ Updated ${fieldUpdate.apiName} with UI metadata`);
|
||||
} else {
|
||||
console.log(`✗ Field ${fieldUpdate.apiName} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ Account fields UI metadata seed completed successfully!');
|
||||
console.log('You can now fetch the Account object UI config via:');
|
||||
console.log('GET /api/setup/objects/Account/ui-config');
|
||||
};
|
||||
@@ -42,13 +42,8 @@ export class AuthController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
|
||||
if (!tenantId) {
|
||||
throw new UnauthorizedException('Tenant ID is required');
|
||||
}
|
||||
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
const user = await this.authService.validateUser(
|
||||
tenantId,
|
||||
loginDto.email,
|
||||
loginDto.password,
|
||||
);
|
||||
@@ -62,15 +57,9 @@ export class AuthController {
|
||||
|
||||
@Post('register')
|
||||
async register(
|
||||
@TenantId() tenantId: string,
|
||||
@Body() registerDto: RegisterDto,
|
||||
) {
|
||||
if (!tenantId) {
|
||||
throw new UnauthorizedException('Tenant ID is required');
|
||||
}
|
||||
|
||||
const user = await this.authService.register(
|
||||
tenantId,
|
||||
registerDto.email,
|
||||
registerDto.password,
|
||||
registerDto.firstName,
|
||||
|
||||
@@ -11,19 +11,14 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
async validateUser(
|
||||
tenantId: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<any> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
tenantId_email: {
|
||||
tenantId,
|
||||
email,
|
||||
},
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
@@ -52,7 +47,6 @@ export class AuthService {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -62,13 +56,11 @@ export class AuthService {
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
tenantId: user.tenantId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async register(
|
||||
tenantId: string,
|
||||
email: string,
|
||||
password: string,
|
||||
firstName?: string,
|
||||
@@ -78,7 +70,6 @@ export class AuthService {
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
// Setup endpoints - Object metadata management
|
||||
async getObjectDefinitions(tenantId: string) {
|
||||
return this.prisma.objectDefinition.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
return knex('object_definitions')
|
||||
.select('*')
|
||||
.orderBy('label', 'asc');
|
||||
}
|
||||
|
||||
async getObjectDefinition(tenantId: string, apiName: string) {
|
||||
const obj = await this.prisma.objectDefinition.findUnique({
|
||||
where: {
|
||||
tenantId_apiName: {
|
||||
tenantId,
|
||||
apiName,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
where: { isActive: true },
|
||||
orderBy: { label: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const obj = await knex('object_definitions')
|
||||
.where({ apiName })
|
||||
.first();
|
||||
|
||||
if (!obj) {
|
||||
throw new NotFoundException(`Object ${apiName} not found`);
|
||||
}
|
||||
|
||||
return obj;
|
||||
// Get fields for this object
|
||||
const fields = await knex('field_definitions')
|
||||
.where({ objectDefinitionId: obj.id })
|
||||
.orderBy('label', 'asc');
|
||||
|
||||
return {
|
||||
...obj,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
async createObjectDefinition(
|
||||
@@ -49,13 +45,15 @@ export class ObjectService {
|
||||
isSystem?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.objectDefinition.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...data,
|
||||
tableName: `custom_${data.apiName.toLowerCase()}`,
|
||||
},
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const [id] = await knex('object_definitions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('object_definitions').where({ id }).first();
|
||||
}
|
||||
|
||||
async createFieldDefinition(
|
||||
@@ -68,20 +66,22 @@ export class ObjectService {
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
isUnique?: boolean;
|
||||
isLookup?: boolean;
|
||||
referenceTo?: string;
|
||||
referenceObject?: string;
|
||||
defaultValue?: string;
|
||||
options?: any;
|
||||
},
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const obj = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
|
||||
return this.prisma.fieldDefinition.create({
|
||||
data: {
|
||||
objectId: obj.id,
|
||||
...data,
|
||||
},
|
||||
const [id] = await knex('field_definitions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
objectDefinitionId: obj.id,
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('field_definitions').where({ id }).first();
|
||||
}
|
||||
|
||||
// Runtime endpoints - CRUD operations
|
||||
@@ -91,19 +91,16 @@ export class ObjectService {
|
||||
userId: string,
|
||||
filters?: any,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
// For demonstration, using Account as example static object
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
ownerId: userId, // Basic sharing rule
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return knex('accounts')
|
||||
.where({ ownerId: userId })
|
||||
.where(filters || {});
|
||||
}
|
||||
|
||||
// For custom objects, you'd need dynamic query building
|
||||
// This is a simplified version
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
|
||||
@@ -113,14 +110,12 @@ export class ObjectService {
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
const record = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
id: recordId,
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
const record = await knex('accounts')
|
||||
.where({ id: recordId, ownerId: userId })
|
||||
.first();
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
@@ -138,14 +133,18 @@ export class ObjectService {
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
...data,
|
||||
},
|
||||
const [id] = await knex('accounts').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
ownerId: userId,
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('accounts').where({ id }).first();
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
@@ -158,14 +157,17 @@ export class ObjectService {
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.update({
|
||||
where: { id: recordId },
|
||||
data,
|
||||
});
|
||||
await knex('accounts')
|
||||
.where({ id: recordId })
|
||||
.update({ ...data, updated_at: knex.fn.now() });
|
||||
|
||||
return knex('accounts').where({ id: recordId }).first();
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
@@ -177,13 +179,15 @@ export class ObjectService {
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.delete({
|
||||
where: { id: recordId },
|
||||
});
|
||||
await knex('accounts').where({ id: recordId }).delete();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from '.prisma/tenant';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
|
||||
Reference in New Issue
Block a user