25 Commits

Author SHA1 Message Date
Francisco Gaona
c21274c86f WIP - record sharing 2025-12-28 21:31:02 +01:00
Francisco Gaona
358a216015 WIP - fix check for default writable 2025-12-28 20:39:44 +01:00
Francisco Gaona
868ce596ba WIP - remove console logs 2025-12-28 07:20:58 +01:00
Francisco Gaona
9aeb6d2b6f WIP - more fix for permissions 2025-12-28 07:18:47 +01:00
Francisco Gaona
ac4a4b68cd WIP - more progress with permissions 2025-12-28 06:48:03 +01:00
Francisco Gaona
88f656c3f5 WIP - permissions 2025-12-28 05:43:03 +01:00
Francisco Gaona
f4143ab106 WIP - Fix objection and model registry 2025-12-27 06:08:25 +01:00
Francisco Gaona
516e132611 WIP - move docs 2025-12-24 21:46:05 +01:00
Francisco Gaona
c5305490c1 WIP - use objection and working lookup field to owner 2025-12-24 21:43:58 +01:00
Francisco Gaona
4520f94b69 WIP - using objection base model to handle objects operations 2025-12-24 20:18:43 +01:00
Francisco Gaona
e4f1ba96ad WIP - custom migrations when object is created 2025-12-24 19:54:13 +01:00
Francisco Gaona
52c0849de2 WIP - manage tenant users from central 2025-12-24 12:17:22 +01:00
Francisco Gaona
b9fa3bd008 WIP - improve login to tenants by domains 2025-12-24 11:42:44 +01:00
Francisco Gaona
2bc672e4c5 WIP - some fixes 2025-12-24 10:54:19 +01:00
Francisco Gaona
962c84e6d2 WIP - fix lookup field 2025-12-24 00:05:15 +01:00
Francisco Gaona
fc1bec4de7 WIP - related lists and look up field 2025-12-23 23:59:04 +01:00
Francisco Gaona
0275b96014 WIP - central operations 2025-12-23 23:38:45 +01:00
Francisco Gaona
e4f3bad971 WIp - fix login into central 2025-12-23 22:16:58 +01:00
Francisco Gaona
838a010fb2 Added page layouts 2025-12-23 09:44:05 +01:00
Francisco Gaona
be6e34914e Added better display of bread crums and side bar menus for apps and objects 2025-12-22 11:01:53 +01:00
Francisco Gaona
db9848cce7 Use routes closer to the route for objects 2025-12-22 10:24:02 +01:00
Francisco Gaona
cdc202454f Redirect to detail view of newly created record 2025-12-22 09:55:15 +01:00
Francisco Gaona
f4067c56b4 Added basic crud for objects 2025-12-22 09:36:39 +01:00
Francisco Gaona
0fe56c0e03 Added auth functionality, initial work with views and field types 2025-12-22 03:31:55 +01:00
Francisco Gaona
859dca6c84 Fix flashing styles on initial load 2025-11-26 01:39:15 +01:00
246 changed files with 26228 additions and 518 deletions

View File

@@ -2,8 +2,12 @@ NODE_ENV=development
PORT=3000
DATABASE_URL="mysql://platform:platform@db:3306/platform"
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
REDIS_URL="redis://redis:6379"
# JWT, multi-tenant hints, etc.
JWT_SECRET="devsecret"
TENANCY_STRATEGY="single-db"
CENTRAL_SUBDOMAINS="central,admin"

251
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,251 @@
# Authorization System Implementation Summary
## ✅ Implementation Complete
A comprehensive polymorphic record sharing and authorization system has been implemented with CASL, Objection.js, and NestJS.
## What Was Built
### Backend (NestJS + Objection.js + CASL)
#### 1. Database Layer
- ✅ Migration for authorization tables (`20250128000001_add_authorization_system.js`)
- ✅ Updated Prisma schema with new models
- ✅ Objection.js models: `ObjectField`, `RoleRule`, `RecordShare`
- ✅ Updated existing models with new relations
#### 2. Authorization Core
-`AbilityFactory` - Builds CASL abilities from 3 layers (global, role, share)
- ✅ Query scoping utilities for SQL-level authorization
- ✅ Guards and decorators (`AbilitiesGuard`, `@CheckAbility()`, `@CurrentUser()`)
- ✅ Middleware for attaching abilities to requests
#### 3. API Endpoints
-**ShareController** - CRUD for record shares
- POST /shares - Create share
- GET /shares/record/:objectDefinitionId/:recordId - List shares
- GET /shares/granted - Shares granted by user
- GET /shares/received - Shares received by user
- PATCH /shares/:id - Update share
- DELETE /shares/:id - Revoke share
-**RoleController** - Role management
- Standard CRUD for roles
- RoleRuleController for CASL rules
-**ObjectAccessController** - Object-level permissions
- GET/PUT /setup/objects/:apiName/access
- POST /setup/objects/:apiName/fields/:fieldKey/permissions
- PUT /setup/objects/:apiName/field-permissions
### Frontend (Nuxt 3 + Vue 3)
#### 4. Object Management Enhancement
- ✅ Added "Access & Permissions" tab to object setup page
-`ObjectAccessSettings.vue` component:
- Configure access model (public/owner/mixed)
- Set public CRUD permissions
- Configure owner field
- Set field-level read/write permissions
#### 5. Role Management
- ✅ New page: `/setup/roles`
-`RolePermissionsEditor.vue` component:
- Configure CRUD permissions per object
- Apply conditions (e.g., own records only)
- Visual permission matrix
#### 6. Record Sharing
-`RecordShareDialog.vue` component:
- List current shares
- Add new shares with permissions
- Field-level scoping
- Expiration dates
- Revoke shares
## Key Features
### 🌍 Global Object Policies
- Public/private access models
- Default CRUD permissions per object
- Configurable owner field
- Field-level default permissions
### 👥 Role-Based Access
- CASL rules stored in database
- Per-object permissions
- Condition-based rules (e.g., ownerId matching)
- Multiple actions per rule
### 🔗 Per-Record Sharing
- Polymorphic design (works with any object type)
- Grant read/update access to specific users
- Optional field-level scoping
- Expiration and revocation support
- Track who granted each share
### 🔒 SQL Query Scoping
- Critical for list endpoints
- Ensures users only see authorized records
- Combines ownership + sharing logic
- Works with public access flags
## File Structure
```
backend/
├── migrations/tenant/
│ └── 20250128000001_add_authorization_system.js
├── src/
│ ├── auth/
│ │ ├── ability.factory.ts (CASL ability builder)
│ │ ├── query-scope.util.ts (SQL scoping utilities)
│ │ ├── guards/
│ │ │ └── abilities.guard.ts
│ │ ├── decorators/
│ │ │ ├── auth.decorators.ts
│ │ │ └── check-ability.decorator.ts
│ │ └── middleware/
│ │ └── ability.middleware.ts
│ ├── models/
│ │ ├── object-field.model.ts
│ │ ├── role-rule.model.ts
│ │ └── record-share.model.ts
│ ├── rbac/
│ │ ├── share.controller.ts
│ │ └── role.controller.ts
│ └── object/
│ └── object-access.controller.ts
frontend/
├── components/
│ ├── ObjectAccessSettings.vue
│ ├── RecordShareDialog.vue
│ └── RolePermissionsEditor.vue
└── pages/
├── setup/
│ ├── objects/[apiName].vue (enhanced with access tab)
│ └── roles.vue
└── ...
docs/
└── AUTHORIZATION_SYSTEM.md (comprehensive documentation)
```
## Next Steps
### 1. Run the Migration
```bash
cd backend
npm run migrate:latest
```
### 2. Initialize Existing Objects
Set default access models for existing object definitions:
```sql
UPDATE object_definitions
SET
access_model = 'owner',
public_read = false,
public_create = false,
public_update = false,
public_delete = false,
owner_field = 'ownerId'
WHERE access_model IS NULL;
```
### 3. Apply Query Scoping
Update existing controllers to use query scoping:
```typescript
import { applyReadScope } from '@/auth/query-scope.util';
// In your list endpoint
async findAll(@CurrentUser() user: User) {
const objectDef = await ObjectDefinition.query(this.knex)
.findOne({ apiName: 'YourObject' });
let query = YourModel.query(this.knex);
query = applyReadScope(query, user, objectDef, this.knex);
return query;
}
```
### 4. Add Route Protection
Use guards on sensitive endpoints:
```typescript
@UseGuards(JwtAuthGuard, AbilitiesGuard)
@CheckAbility({ action: 'update', subject: 'Post' })
async update(@Body() data: any) {
// Only users with 'update' permission on 'Post' can access
}
```
### 5. Frontend Integration
Add sharing button to record detail pages:
```vue
<template>
<div>
<!-- Your record details -->
<Button @click="showShareDialog = true">
<Share class="w-4 h-4 mr-2" />
Share
</Button>
<RecordShareDialog
:open="showShareDialog"
:object-definition-id="objectDefinition.id"
:record-id="record.id"
:fields="objectDefinition.fields"
@close="showShareDialog = false"
/>
</div>
</template>
```
## Testing Checklist
- [ ] Run database migration successfully
- [ ] Create a test role with permissions
- [ ] Configure object access settings via UI
- [ ] Share a record with another user
- [ ] Verify shared record appears in grantee's list
- [ ] Verify query scoping filters unauthorized records
- [ ] Test field-level permissions
- [ ] Test share expiration
- [ ] Test share revocation
- [ ] Test role-based access with conditions
## Performance Considerations
1. **Index Usage**: The migration creates proper indexes on foreign keys and commonly queried columns
2. **Query Scoping**: Uses SQL EXISTS subqueries for efficient filtering
3. **Ability Caching**: Consider caching abilities per request (already done via middleware)
4. **Batch Loading**: When checking multiple records, batch the share lookups
## Security Notes
⚠️ **Important**: Always use SQL query scoping for list endpoints. Never fetch all records and filter in application code.
**Best Practices**:
- Share creation requires ownership verification
- Only grantors can update/revoke shares
- Expired/revoked shares are excluded from queries
- Field-level permissions are enforced on write operations
## Documentation
Full documentation available in:
- [AUTHORIZATION_SYSTEM.md](./AUTHORIZATION_SYSTEM.md) - Comprehensive guide
- Inline code comments in all new files
- JSDoc comments on key functions
## Support
For questions or issues:
1. Check the documentation in `docs/AUTHORIZATION_SYSTEM.md`
2. Review example usage in the controllers
3. Examine the test cases (when added)

23
backend/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# 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"
# Central Admin Subdomains (comma-separated list of subdomains that access the central database)
CENTRAL_SUBDOMAINS="central,admin"

View File

@@ -0,0 +1,91 @@
╔══════════════════════════════════════════════════════════════════════╗
║ TENANT MIGRATION - QUICK REFERENCE ║
╚══════════════════════════════════════════════════════════════════════╝
📍 LOCATION: /root/neo/backend
┌─────────────────────────────────────────────────────────────────────┐
│ COMMON COMMANDS │
└─────────────────────────────────────────────────────────────────────┘
Create Migration:
$ npm run migrate:make add_my_feature
Check Status:
$ npm run migrate:status
Test on One Tenant:
$ npm run migrate:tenant acme-corp
Apply to All Tenants:
$ npm run migrate:all-tenants
┌─────────────────────────────────────────────────────────────────────┐
│ ALL AVAILABLE COMMANDS │
└─────────────────────────────────────────────────────────────────────┘
npm run migrate:make <name> Create new migration file
npm run migrate:status Check status across all tenants
npm run migrate:tenant <slug> Migrate specific tenant
npm run migrate:all-tenants Migrate all active tenants
npm run migrate:latest Migrate default DB (rarely used)
npm run migrate:rollback Rollback default DB (rarely used)
┌─────────────────────────────────────────────────────────────────────┐
│ TYPICAL WORKFLOW │
└─────────────────────────────────────────────────────────────────────┘
1. Create: npm run migrate:make add_priority_field
2. Edit: vim migrations/tenant/20250127_*.js
3. Test: npm run migrate:tenant test-company
4. Status: npm run migrate:status
5. Deploy: npm run migrate:all-tenants
┌─────────────────────────────────────────────────────────────────────┐
│ ENVIRONMENT REQUIRED │
└─────────────────────────────────────────────────────────────────────┘
export DB_ENCRYPTION_KEY="your-32-character-secret-key!!"
┌─────────────────────────────────────────────────────────────────────┐
│ FILE LOCATIONS │
└─────────────────────────────────────────────────────────────────────┘
Scripts: backend/scripts/migrate-*.ts
Migrations: backend/migrations/tenant/
Config: backend/knexfile.js
Docs: TENANT_MIGRATION_GUIDE.md
┌─────────────────────────────────────────────────────────────────────┐
│ DOCUMENTATION │
└─────────────────────────────────────────────────────────────────────┘
Quick Guide: cat TENANT_MIGRATION_GUIDE.md
Script Docs: cat backend/scripts/README.md
Complete: cat TENANT_MIGRATION_IMPLEMENTATION_COMPLETE.md
┌─────────────────────────────────────────────────────────────────────┐
│ TROUBLESHOOTING │
└─────────────────────────────────────────────────────────────────────┘
Missing Prisma Client:
$ npx prisma generate --schema=prisma/schema-central.prisma
Check Scripts Available:
$ npm run | grep migrate
Connection Error:
- Check DB_ENCRYPTION_KEY matches encryption key
- Verify central database is accessible
- Ensure tenant databases are online
╔══════════════════════════════════════════════════════════════════════╗
║ For detailed help: cat TENANT_MIGRATION_GUIDE.md ║
╚══════════════════════════════════════════════════════════════════════╝

19
backend/knexfile.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
exports.up = function (knex) {
return knex.schema.createTable('custom_migrations', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('tenantId').notNullable();
table.string('name', 255).notNullable();
table.text('description');
table.enum('type', [
'create_table',
'add_column',
'alter_column',
'add_index',
'drop_table',
'custom',
]).notNullable();
table.text('sql').notNullable();
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
table.timestamp('executedAt').nullable();
table.text('error').nullable();
table.timestamps(true, true);
table.index(['tenantId']);
table.index(['status']);
table.index(['created_at']);
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('custom_migrations');
};

View File

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

View File

@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.table('field_definitions', (table) => {
table.jsonb('ui_metadata').nullable().comment('JSON metadata for UI rendering including display options, validation rules, and field-specific configurations');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.table('field_definitions', (table) => {
table.dropColumn('ui_metadata');
});
};

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.string('nameField', 255).comment('API name of the field to use as record display name');
});
};
exports.down = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.dropColumn('nameField');
});
};

View File

@@ -0,0 +1,22 @@
exports.up = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.uuid('app_id').nullable()
.comment('Optional: App that this object belongs to');
table
.foreign('app_id')
.references('id')
.inTable('apps')
.onDelete('SET NULL');
table.index(['app_id']);
});
};
exports.down = function (knex) {
return knex.schema.table('object_definitions', (table) => {
table.dropForeign('app_id');
table.dropIndex('app_id');
table.dropColumn('app_id');
});
};

View File

@@ -0,0 +1,29 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.createTable('page_layouts', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('name').notNullable();
table.uuid('object_id').notNullable();
table.boolean('is_default').defaultTo(false);
table.json('layout_config').notNullable();
table.text('description');
table.timestamps(true, true);
// Foreign key to object_definitions
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
// Index for faster lookups
table.index(['object_id', 'is_default']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.dropTable('page_layouts');
};

View File

@@ -0,0 +1,101 @@
/**
* Migration: Add authorization system (CASL + polymorphic sharing)
*
* This migration adds:
* 1. Access control fields to object_definitions
* 2. Field-level permissions to field_definitions
* 3. role_rules table for CASL rules storage
* 4. record_shares table for polymorphic per-record sharing
*/
exports.up = async function(knex) {
// 1. Add access control fields to object_definitions
await knex.schema.table('object_definitions', (table) => {
table.enum('access_model', ['public', 'owner', 'mixed']).defaultTo('owner');
table.boolean('public_read').defaultTo(false);
table.boolean('public_create').defaultTo(false);
table.boolean('public_update').defaultTo(false);
table.boolean('public_delete').defaultTo(false);
table.string('owner_field', 100).defaultTo('ownerId');
});
// 2. Add field-level permission columns to field_definitions
await knex.schema.table('field_definitions', (table) => {
table.boolean('default_readable').defaultTo(true);
table.boolean('default_writable').defaultTo(true);
});
// 3. Create role_rules table for storing CASL rules per role
await knex.schema.createTable('role_rules', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('role_id').notNullable();
table.json('rules_json').notNullable(); // Array of CASL rules
table.timestamps(true, true);
// Foreign keys
table.foreign('role_id')
.references('id')
.inTable('roles')
.onDelete('CASCADE');
// Indexes
table.index('role_id');
});
// 4. Create record_shares table for polymorphic per-record sharing
await knex.schema.createTable('record_shares', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('object_definition_id').notNullable();
table.string('record_id', 255).notNullable(); // String to support UUID/int uniformly
table.uuid('grantee_user_id').notNullable();
table.uuid('granted_by_user_id').notNullable();
table.json('actions').notNullable(); // Array like ["read"], ["read","update"]
table.json('fields').nullable(); // Optional field scoping
table.timestamp('expires_at').nullable();
table.timestamp('revoked_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
// Foreign keys
table.foreign('object_definition_id')
.references('id')
.inTable('object_definitions')
.onDelete('CASCADE');
table.foreign('grantee_user_id')
.references('id')
.inTable('users')
.onDelete('CASCADE');
table.foreign('granted_by_user_id')
.references('id')
.inTable('users')
.onDelete('CASCADE');
// Indexes for efficient querying
table.index(['grantee_user_id', 'object_definition_id']);
table.index(['object_definition_id', 'record_id']);
table.unique(['object_definition_id', 'record_id', 'grantee_user_id']);
});
};
exports.down = async function(knex) {
// Drop tables in reverse order
await knex.schema.dropTableIfExists('record_shares');
await knex.schema.dropTableIfExists('role_rules');
// Remove columns from field_definitions
await knex.schema.table('field_definitions', (table) => {
table.dropColumn('default_readable');
table.dropColumn('default_writable');
});
// Remove columns from object_definitions
await knex.schema.table('object_definitions', (table) => {
table.dropColumn('access_model');
table.dropColumn('public_read');
table.dropColumn('public_create');
table.dropColumn('public_update');
table.dropColumn('public_delete');
table.dropColumn('owner_field');
});
};

View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@casl/ability": "^6.7.5",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
@@ -22,6 +23,10 @@
"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",
"objection-authorize": "^5.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
@@ -738,6 +743,18 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@casl/ability": {
"version": "6.7.5",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.5.tgz",
"integrity": "sha512-NaOHPi9JMn8Kesh+GRkjNKAYkl4q8qMFAlqw7w2yrE+cBQZSbV9GkBGKvgzs3CdzEc5Yl1cn3JwDxxbBN5gjog==",
"license": "MIT",
"dependencies": {
"@ucast/mongo2js": "^1.3.0"
},
"funding": {
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -2879,6 +2896,41 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@ucast/core": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz",
"integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==",
"license": "Apache-2.0"
},
"node_modules/@ucast/js": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz",
"integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==",
"license": "Apache-2.0",
"dependencies": {
"@ucast/core": "^1.0.0"
}
},
"node_modules/@ucast/mongo": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz",
"integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==",
"license": "Apache-2.0",
"dependencies": {
"@ucast/core": "^1.4.1"
}
},
"node_modules/@ucast/mongo2js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.0.tgz",
"integrity": "sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w==",
"license": "Apache-2.0",
"dependencies": {
"@ucast/core": "^1.6.1",
"@ucast/js": "^3.0.0",
"@ucast/mongo": "^2.4.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -3341,6 +3393,15 @@
"fastq": "^1.17.1"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -4016,6 +4077,12 @@
"color-support": "bin.js"
}
},
"node_modules/colorette": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==",
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -4167,6 +4234,12 @@
"node": ">= 8"
}
},
"node_modules/db-errors": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/db-errors/-/db-errors-0.2.3.tgz",
"integrity": "sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4262,6 +4335,15 @@
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4473,7 +4555,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4684,6 +4765,15 @@
"node": "*"
}
},
"node_modules/esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@@ -5317,7 +5407,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -5350,6 +5439,15 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -5399,7 +5497,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
@@ -5432,6 +5529,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/getopts": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -5640,7 +5743,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5656,6 +5758,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -5813,6 +5935,15 @@
"node": ">=12.0.0"
}
},
"node_modules/interpret": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
@@ -5870,7 +6001,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -5954,6 +6084,12 @@
"node": ">=8"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -6983,6 +7119,98 @@
"node": ">=6"
}
},
"node_modules/knex": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz",
"integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==",
"license": "MIT",
"dependencies": {
"colorette": "2.0.19",
"commander": "^10.0.0",
"debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
"get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
"pg-connection-string": "2.6.2",
"rechoir": "^0.8.0",
"resolve-from": "^5.0.0",
"tarn": "^3.0.2",
"tildify": "2.0.0"
},
"bin": {
"knex": "bin/cli.js"
},
"engines": {
"node": ">=16"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
},
"mysql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/knex/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/knex/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/knex/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/knex/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -7168,6 +7396,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -7178,6 +7412,21 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@@ -7473,6 +7722,63 @@
"dev": true,
"license": "ISC"
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/named-placeholders/node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -7618,6 +7924,68 @@
"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-authorize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/objection-authorize/-/objection-authorize-5.0.2.tgz",
"integrity": "sha512-EAZw2lVajv6TXe24W7jzX5X7uSqQcuMA/ssqMzvIDG4CkstGVZJp23PwkjN4+btNjxKjGk4fMfM6yM3HEJekog==",
"license": "LGPL-3.0",
"dependencies": {
"http-errors": "^2.0.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"objection": "^3"
}
},
"node_modules/objection/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/objection/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@@ -7860,7 +8228,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@@ -7908,6 +8275,12 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8309,6 +8682,18 @@
"node": ">= 12.13.0"
}
},
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
"license": "MIT",
"dependencies": {
"resolve": "^1.20.0"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@ -8369,7 +8754,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -8619,7 +9003,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/schema-utils": {
@@ -8693,6 +9076,11 @@
"node": ">=10"
}
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
@@ -8733,6 +9121,12 @@
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -8842,6 +9236,15 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -8871,6 +9274,15 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -9015,7 +9427,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9096,6 +9507,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/terser": {
"version": "5.44.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
@@ -9292,6 +9712,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/tildify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -9334,6 +9763,15 @@
"node": ">=12"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz",

View File

@@ -17,24 +17,35 @@
"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": {
"@casl/ability": "^6.7.5",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-fastify": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/config": "^3.1.1",
"@nestjs/bullmq": "^10.1.0",
"@nestjs/platform-fastify": "^10.3.0",
"@prisma/client": "^5.8.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"bcrypt": "^5.1.1",
"bullmq": "^5.1.0",
"ioredis": "^5.3.2",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.3.2",
"knex": "^3.1.0",
"mysql2": "^3.15.3",
"objection": "^3.1.5",
"objection-authorize": "^5.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
},
@@ -42,11 +53,11 @@
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0",
"@types/bcrypt": "^5.0.2",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`firstName` VARCHAR(191) NULL,
`lastName` VARCHAR(191) NULL,
`role` VARCHAR(191) NOT NULL DEFAULT 'admin',
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
provider = "mysql"

View File

@@ -0,0 +1,55 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/central"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("CENTRAL_DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
firstName String?
lastName String?
role String @default("admin") // admin, superadmin
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique // Used for identification
dbHost String // Database host
dbPort Int @default(3306)
dbName String // Database name
dbUsername String // Database username
dbPassword String // Encrypted database password
status String @default("active") // active, suspended, deleted
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
domains Domain[]
@@map("tenants")
}
model Domain {
id String @id @default(cuid())
domain String @unique // e.g., "acme" for acme.yourapp.com
tenantId String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("domains")
}

View File

@@ -1,39 +1,22 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Tenant-specific database schema
// This schema is applied to each tenant's database
// NOTE: Each tenant has its own database, so there is NO tenantId column in these tables
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
output = "../node_modules/.prisma/tenant"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// Multi-tenancy
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
objectDefinitions ObjectDefinition[]
accounts Account[]
apps App[]
roles Role[]
permissions Permission[]
@@map("tenants")
url = env("TENANT_DATABASE_URL")
}
// User & Auth
model User {
id String @id @default(uuid())
tenantId String
email String
email String @unique
password String
firstName String?
lastName String?
@@ -41,48 +24,42 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
userRoles UserRole[]
accounts Account[]
userRoles UserRole[]
accounts Account[]
sharesGranted RecordShare[] @relation("GrantedShares")
sharesReceived RecordShare[] @relation("ReceivedShares")
@@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[]
roleRules RoleRule[]
@@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")
}
@@ -116,69 +93,111 @@ model RolePermission {
@@map("role_permissions")
}
// CASL Rules for Roles
model RoleRule {
id String @id @default(uuid())
roleId String
rulesJson Json @map("rules_json")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@index([roleId])
@@map("role_rules")
}
// Object Definition (Metadata)
model ObjectDefinition {
id String @id @default(uuid())
tenantId String
apiName String
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
id String @id @default(uuid())
apiName String @unique
label String
pluralLabel String?
description String? @db.Text
isSystem Boolean @default(false)
isCustom Boolean @default(true)
// Authorization fields
accessModel String @default("owner") // 'public' | 'owner' | 'mixed'
publicRead Boolean @default(false)
publicCreate Boolean @default(false)
publicUpdate Boolean @default(false)
publicDelete Boolean @default(false)
ownerField String @default("ownerId")
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[]
fields FieldDefinition[]
pages AppPage[]
recordShares RecordShare[]
@@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)
uiMetadata Json? @map("ui_metadata")
// Field-level permissions
defaultReadable Boolean @default(true)
defaultWritable Boolean @default(true)
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")
}
// Polymorphic per-record sharing
model RecordShare {
id String @id @default(uuid())
objectDefinitionId String
recordId String
granteeUserId String
grantedByUserId String
actions Json // Array like ["read"], ["read","update"]
fields Json? // Optional field scoping
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
objectDefinition ObjectDefinition @relation(fields: [objectDefinitionId], references: [id], onDelete: Cascade)
granteeUser User @relation("ReceivedShares", fields: [granteeUserId], references: [id], onDelete: Cascade)
grantedByUser User @relation("GrantedShares", fields: [grantedByUserId], references: [id], onDelete: Cascade)
@@unique([objectDefinitionId, recordId, granteeUserId])
@@index([granteeUserId, objectDefinitionId])
@@index([objectDefinitionId, recordId])
@@map("record_shares")
}
// Example static object: Account
model Account {
id String @id @default(uuid())
tenantId String
name String
status String @default("active")
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id])
@@index([tenantId])
@@index([ownerId])
@@map("accounts")
}
@@ -186,8 +205,7 @@ model Account {
// Application Builder
model App {
id String @id @default(uuid())
tenantId String
slug String
slug String @unique
label String
description String? @db.Text
icon String?
@@ -195,11 +213,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")
}

239
backend/scripts/README.md Normal file
View File

@@ -0,0 +1,239 @@
# Tenant Migration & Admin Scripts
This directory contains scripts for managing database migrations across all tenants and creating admin users in the multi-tenant platform.
## Admin User Management
### Create Central Admin User
```bash
npm run create-central-admin
```
Creates an administrator user in the **central database**. Central admins can:
- Manage tenants (create, update, delete)
- Access platform-wide administration features
- View all tenant information
- Manage tenant provisioning
**Interactive Mode:**
```bash
npm run create-central-admin
# You will be prompted for:
# - Email
# - Password
# - First Name (optional)
# - Last Name (optional)
# - Role (admin or superadmin)
```
**Non-Interactive Mode (using environment variables):**
```bash
EMAIL=admin@example.com PASSWORD=securepass123 FIRST_NAME=John LAST_NAME=Doe ROLE=superadmin npm run create-central-admin
```
**Logging In as Central Admin:**
1. Access the application using a central subdomain (e.g., `central.yourdomain.com` or `admin.yourdomain.com`)
2. Enter your central admin credentials
3. You'll be authenticated against the central database (not a tenant database)
**Note:** The system automatically detects if you're logging in from a central subdomain based on the `CENTRAL_SUBDOMAINS` environment variable (defaults to `central,admin`). No special UI or configuration is needed on the frontend.
### Create Tenant User
For creating users within a specific tenant database, use:
```bash
npm run create-tenant-user <tenant-slug>
# (Note: This script may need to be created or already exists)
```
## Migration 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
```

View 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);
});

View File

@@ -0,0 +1,50 @@
import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central';
import * as bcrypt from 'bcrypt';
// Central database client
const centralPrisma = new CentralPrismaClient();
async function createAdminUser() {
const email = 'admin@example.com';
const password = 'admin123';
const firstName = 'Admin';
const lastName = 'User';
try {
// Check if admin user already exists
const existingUser = await centralPrisma.user.findUnique({
where: { email },
});
if (existingUser) {
console.log(`User ${email} already exists`);
return;
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create admin user in central database
const user = await centralPrisma.user.create({
data: {
email,
password: hashedPassword,
firstName,
lastName,
role: 'superadmin',
isActive: true,
},
});
console.log('\nAdmin user created successfully!');
console.log('Email:', email);
console.log('Password:', password);
console.log('User ID:', user.id);
} catch (error) {
console.error('Error creating admin user:', error);
} finally {
await centralPrisma.$disconnect();
}
}
createAdminUser();

View File

@@ -0,0 +1,138 @@
import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central';
import * as bcrypt from 'bcrypt';
import { Knex, knex } from 'knex';
// Central database client
const centralPrisma = new CentralPrismaClient();
async function createTenantUser() {
const tenantSlug = 'tenant1';
const email = 'user@example.com';
const password = 'user123';
const firstName = 'Test';
const lastName = 'User';
try {
// Get tenant database connection info
const tenant = await centralPrisma.tenant.findFirst({
where: { slug: tenantSlug },
});
if (!tenant) {
console.log(`Tenant ${tenantSlug} not found. Creating tenant...`);
// Create tenant in central database
const newTenant = await centralPrisma.tenant.create({
data: {
name: 'Default Tenant',
slug: tenantSlug,
dbHost: 'db',
dbPort: 3306,
dbName: 'platform',
dbUsername: 'platform',
dbPassword: 'platform',
status: 'active',
},
});
console.log('Tenant created:', newTenant.slug);
} else {
console.log('Tenant found:', tenant.slug);
}
const tenantInfo = tenant || {
dbHost: 'db',
dbPort: 3306,
dbName: 'platform',
dbUsername: 'platform',
dbPassword: 'platform',
};
// Connect to tenant database (using root for now since tenant password is encrypted)
const tenantDb: Knex = knex({
client: 'mysql2',
connection: {
host: tenantInfo.dbHost,
port: tenantInfo.dbPort,
database: tenantInfo.dbName,
user: 'root',
password: 'asjdnfqTash37faggT',
},
});
// Check if user already exists
const existingUser = await tenantDb('users')
.where({ email })
.first();
if (existingUser) {
console.log(`User ${email} already exists in tenant ${tenantSlug}`);
await tenantDb.destroy();
return;
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
await tenantDb('users').insert({
email,
password: hashedPassword,
firstName,
lastName,
isActive: true,
created_at: new Date(),
updated_at: new Date(),
});
console.log(`\nUser created successfully in tenant ${tenantSlug}!`);
console.log('Email:', email);
console.log('Password:', password);
// Create admin role if it doesn't exist
let adminRole = await tenantDb('roles')
.where({ name: 'admin' })
.first();
if (!adminRole) {
await tenantDb('roles').insert({
name: 'admin',
guardName: 'api',
description: 'Administrator role with full access',
created_at: new Date(),
updated_at: new Date(),
});
adminRole = await tenantDb('roles')
.where({ name: 'admin' })
.first();
console.log('Admin role created');
}
// Get the created user
const user = await tenantDb('users')
.where({ email })
.first();
// Assign admin role to user
if (adminRole && user) {
await tenantDb('user_roles').insert({
userId: user.id,
roleId: adminRole.id,
created_at: new Date(),
updated_at: new Date(),
});
console.log('Admin role assigned to user');
}
await tenantDb.destroy();
} catch (error) {
console.error('Error creating tenant user:', error);
} finally {
await centralPrisma.$disconnect();
}
}
createTenantUser();

View File

@@ -0,0 +1,169 @@
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);
// Use Docker hostname 'db' when running inside container
// The dbHost will be 'db' for Docker connections or 'localhost' for local development
const dbHost = tenant.dbHost;
return knex({
client: 'mysql2',
connection: {
host: 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);
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);
});

View 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);
});

View File

@@ -0,0 +1,72 @@
import { getCentralPrisma } from '../src/prisma/central-prisma.service';
import * as knex from 'knex';
import * as crypto from 'crypto';
function decrypt(text: string): string {
const parts = text.split(':');
const iv = Buffer.from(parts.shift()!, 'hex');
const encryptedText = Buffer.from(parts.join(':'), 'hex');
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
key,
iv,
);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
async function updateNameField() {
const centralPrisma = getCentralPrisma();
try {
// Find tenant1
const tenant = await centralPrisma.tenant.findFirst({
where: {
OR: [
{ id: 'tenant1' },
{ slug: 'tenant1' },
],
},
});
if (!tenant) {
console.error('❌ Tenant tenant1 not found');
process.exit(1);
}
console.log(`📋 Tenant: ${tenant.name} (${tenant.slug})`);
console.log(`📊 Database: ${tenant.dbName}`);
// Decrypt password
const password = decrypt(tenant.dbPassword);
// Create connection
const tenantKnex = knex.default({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: password,
database: tenant.dbName,
},
});
// Update Account object
await tenantKnex('object_definitions')
.where({ apiName: 'Account' })
.update({ nameField: 'name' });
console.log('✅ Updated Account object nameField to "name"');
await tenantKnex.destroy();
await centralPrisma.$disconnect();
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
}
}
updateNameField();

View 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');
};

View File

@@ -0,0 +1,349 @@
/**
* Example seed data for Contact object with UI metadata
* Run this after creating the object definition
*/
exports.seed = async function(knex) {
// Get or create the Contact object
const [contactObj] = await knex('object_definitions')
.where({ api_name: 'Contact' })
.select('id');
if (!contactObj) {
console.log('Contact object not found. Please create it first.');
return;
}
// Define fields with UI metadata
const fields = [
{
object_definition_id: contactObj.id,
api_name: 'firstName',
label: 'First Name',
type: 'text',
is_required: true,
is_system: false,
is_custom: false,
display_order: 1,
ui_metadata: {
placeholder: 'Enter first name',
helpText: 'The contact\'s given name',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [
{ type: 'min', value: 2, message: 'First name must be at least 2 characters' },
{ type: 'max', value: 50, message: 'First name cannot exceed 50 characters' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'lastName',
label: 'Last Name',
type: 'text',
is_required: true,
is_system: false,
is_custom: false,
display_order: 2,
ui_metadata: {
placeholder: 'Enter last name',
helpText: 'The contact\'s family name',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [
{ type: 'min', value: 2, message: 'Last name must be at least 2 characters' },
{ type: 'max', value: 50, message: 'Last name cannot exceed 50 characters' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'email',
label: 'Email',
type: 'email',
is_required: true,
is_unique: true,
is_system: false,
is_custom: false,
display_order: 3,
ui_metadata: {
placeholder: 'email@example.com',
helpText: 'Primary email address',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [
{ type: 'email', message: 'Please enter a valid email address' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'phone',
label: 'Phone',
type: 'text',
is_required: false,
is_system: false,
is_custom: false,
display_order: 4,
ui_metadata: {
placeholder: '+1 (555) 000-0000',
helpText: 'Primary phone number',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: false,
validationRules: [
{ type: 'pattern', value: '^\\+?[0-9\\s\\-\\(\\)]+$', message: 'Please enter a valid phone number' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'company',
label: 'Company',
type: 'text',
is_required: false,
is_system: false,
is_custom: false,
display_order: 5,
ui_metadata: {
placeholder: 'Company name',
helpText: 'The organization this contact works for',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
},
{
object_definition_id: contactObj.id,
api_name: 'jobTitle',
label: 'Job Title',
type: 'text',
is_required: false,
is_system: false,
is_custom: false,
display_order: 6,
ui_metadata: {
placeholder: 'e.g., Senior Manager',
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false
}
},
{
object_definition_id: contactObj.id,
api_name: 'status',
label: 'Status',
type: 'picklist',
is_required: true,
is_system: false,
is_custom: false,
display_order: 7,
default_value: 'active',
ui_metadata: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
{ label: 'Archived', value: 'archived' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'leadSource',
label: 'Lead Source',
type: 'picklist',
is_required: false,
is_system: false,
is_custom: false,
display_order: 8,
ui_metadata: {
placeholder: 'Select lead source',
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true,
options: [
{ label: 'Website', value: 'website' },
{ label: 'Referral', value: 'referral' },
{ label: 'Social Media', value: 'social' },
{ label: 'Conference', value: 'conference' },
{ label: 'Cold Call', value: 'cold_call' },
{ label: 'Other', value: 'other' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'isVip',
label: 'VIP Customer',
type: 'boolean',
is_required: false,
is_system: false,
is_custom: false,
display_order: 9,
default_value: 'false',
ui_metadata: {
helpText: 'Mark as VIP for priority support',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
},
{
object_definition_id: contactObj.id,
api_name: 'birthDate',
label: 'Birth Date',
type: 'date',
is_required: false,
is_system: false,
is_custom: false,
display_order: 10,
ui_metadata: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true,
format: 'yyyy-MM-dd'
}
},
{
object_definition_id: contactObj.id,
api_name: 'website',
label: 'Website',
type: 'url',
is_required: false,
is_system: false,
is_custom: false,
display_order: 11,
ui_metadata: {
placeholder: 'https://example.com',
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
validationRules: [
{ type: 'url', message: 'Please enter a valid URL starting with http:// or https://' }
]
}
},
{
object_definition_id: contactObj.id,
api_name: 'mailingAddress',
label: 'Mailing Address',
type: 'textarea',
is_required: false,
is_system: false,
is_custom: false,
display_order: 12,
ui_metadata: {
placeholder: 'Enter full mailing address',
rows: 3,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false
}
},
{
object_definition_id: contactObj.id,
api_name: 'notes',
label: 'Notes',
type: 'textarea',
is_required: false,
is_system: false,
is_custom: false,
display_order: 13,
ui_metadata: {
placeholder: 'Additional notes about this contact...',
rows: 5,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false
}
},
{
object_definition_id: contactObj.id,
api_name: 'annualRevenue',
label: 'Annual Revenue',
type: 'currency',
is_required: false,
is_system: false,
is_custom: false,
display_order: 14,
ui_metadata: {
prefix: '$',
step: 0.01,
min: 0,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
},
{
object_definition_id: contactObj.id,
api_name: 'numberOfEmployees',
label: 'Number of Employees',
type: 'integer',
is_required: false,
is_system: false,
is_custom: false,
display_order: 15,
ui_metadata: {
min: 1,
step: 1,
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true
}
}
];
// Insert or update fields
for (const field of fields) {
const existing = await knex('field_definitions')
.where({
object_definition_id: field.object_definition_id,
api_name: field.api_name
})
.first();
if (existing) {
await knex('field_definitions')
.where({ id: existing.id })
.update({
...field,
ui_metadata: JSON.stringify(field.ui_metadata),
updated_at: knex.fn.now()
});
console.log(`Updated field: ${field.api_name}`);
} else {
await knex('field_definitions').insert({
...field,
ui_metadata: JSON.stringify(field.ui_metadata),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
console.log(`Created field: ${field.api_name}`);
}
}
console.log('Contact fields seeded successfully!');
};

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { AppBuilderService } from './app-builder.service';
import { RuntimeAppController } from './runtime-app.controller';
import { SetupAppController } from './setup-app.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
providers: [AppBuilderService],
controllers: [RuntimeAppController, SetupAppController],
exports: [AppBuilderService],

View File

@@ -1,44 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { App } from '../models/app.model';
import { AppPage } from '../models/app-page.model';
import { ObjectDefinition } from '../models/object-definition.model';
@Injectable()
export class AppBuilderService {
constructor(private prisma: PrismaService) {}
constructor(private tenantDbService: TenantDatabaseService) {}
// Runtime endpoints
async getApps(tenantId: string, userId: string) {
// For now, return all active apps for the tenant
const knex = await this.tenantDbService.getTenantKnex(tenantId);
// For now, return all apps
// In production, you'd filter by user permissions
return this.prisma.app.findMany({
where: {
tenantId,
isActive: true,
},
include: {
pages: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { label: 'asc' },
});
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
}
async getApp(tenantId: string, slug: string, userId: string) {
const app = await this.prisma.app.findUnique({
where: {
tenantId_slug: {
tenantId,
slug,
},
},
include: {
pages: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
});
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await App.query(knex)
.findOne({ slug })
.withGraphFetched('pages');
if (!app) {
throw new NotFoundException(`App ${slug} not found`);
@@ -53,23 +35,12 @@ export class AppBuilderService {
pageSlug: string,
userId: string,
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getApp(tenantId, appSlug, userId);
const page = await this.prisma.appPage.findFirst({
where: {
appId: app.id,
slug: pageSlug,
isActive: true,
},
include: {
object: {
include: {
fields: {
where: { isActive: true },
},
},
},
},
const page = await AppPage.query(knex).findOne({
appId: app.id,
slug: pageSlug,
});
if (!page) {
@@ -81,31 +52,15 @@ export class AppBuilderService {
// Setup endpoints
async getAllApps(tenantId: string) {
return this.prisma.app.findMany({
where: { tenantId },
include: {
pages: {
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { label: 'asc' },
});
const knex = await this.tenantDbService.getTenantKnex(tenantId);
return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
}
async getAppForSetup(tenantId: string, slug: string) {
const app = await this.prisma.app.findUnique({
where: {
tenantId_slug: {
tenantId,
slug,
},
},
include: {
pages: {
orderBy: { sortOrder: 'asc' },
},
},
});
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await App.query(knex)
.findOne({ slug })
.withGraphFetched('pages');
if (!app) {
throw new NotFoundException(`App ${slug} not found`);
@@ -120,14 +75,12 @@ export class AppBuilderService {
slug: string;
label: string;
description?: string;
icon?: string;
},
) {
return this.prisma.app.create({
data: {
tenantId,
...data,
},
const knex = await this.tenantDbService.getTenantKnex(tenantId);
return App.query(knex).insert({
...data,
displayOrder: 0,
});
}
@@ -137,16 +90,12 @@ export class AppBuilderService {
data: {
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, slug);
return this.prisma.app.update({
where: { id: app.id },
data,
});
return App.query(knex).patchAndFetchById(app.id, data);
}
async createPage(
@@ -157,37 +106,19 @@ export class AppBuilderService {
label: string;
type: string;
objectApiName?: string;
config?: any;
sortOrder?: number;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug);
// If objectApiName is provided, find the object
let objectId: string | undefined;
if (data.objectApiName) {
const obj = await this.prisma.objectDefinition.findUnique({
where: {
tenantId_apiName: {
tenantId,
apiName: data.objectApiName,
},
},
});
objectId = obj?.id;
}
return this.prisma.appPage.create({
data: {
appId: app.id,
slug: data.slug,
label: data.label,
type: data.type,
objectApiName: data.objectApiName,
objectId,
config: data.config,
sortOrder: data.sortOrder || 0,
},
return AppPage.query(knex).insert({
appId: app.id,
slug: data.slug,
label: data.label,
type: data.type,
objectApiName: data.objectApiName,
displayOrder: data.sortOrder || 0,
});
}
@@ -199,44 +130,24 @@ export class AppBuilderService {
label?: string;
type?: string;
objectApiName?: string;
config?: any;
sortOrder?: number;
isActive?: boolean;
},
) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug);
const page = await this.prisma.appPage.findFirst({
where: {
appId: app.id,
slug: pageSlug,
},
const page = await AppPage.query(knex).findOne({
appId: app.id,
slug: pageSlug,
});
if (!page) {
throw new NotFoundException(`Page ${pageSlug} not found`);
}
// If objectApiName is provided, find the object
let objectId: string | undefined;
if (data.objectApiName) {
const obj = await this.prisma.objectDefinition.findUnique({
where: {
tenantId_apiName: {
tenantId,
apiName: data.objectApiName,
},
},
});
objectId = obj?.id;
}
return this.prisma.appPage.update({
where: { id: page.id },
data: {
...data,
objectId,
},
return AppPage.query(knex).patchAndFetchById(page.id, {
...data,
displayOrder: data.sortOrder,
});
}
}

View File

@@ -59,11 +59,6 @@ export class SetupAppController {
@Param('pageSlug') pageSlug: string,
@Body() data: any,
) {
return this.appBuilderService.updatePage(
tenantId,
appSlug,
pageSlug,
data,
);
return this.appBuilderService.updatePage(tenantId, appSlug, pageSlug, data);
}
}

View File

@@ -6,6 +6,7 @@ import { AuthModule } from './auth/auth.module';
import { RbacModule } from './rbac/rbac.module';
import { ObjectModule } from './object/object.module';
import { AppBuilderModule } from './app-builder/app-builder.module';
import { PageLayoutModule } from './page-layout/page-layout.module';
@Module({
imports: [
@@ -18,6 +19,7 @@ import { AppBuilderModule } from './app-builder/app-builder.module';
RbacModule,
ObjectModule,
AppBuilderModule,
PageLayoutModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,207 @@
import { Injectable } from '@nestjs/common';
import { Ability, AbilityBuilder, AbilityClass, ExtractSubjectType, InferSubjects, createMongoAbility } from '@casl/ability';
import { User } from '../models/user.model';
import { ObjectDefinition } from '../models/object-definition.model';
import { FieldDefinition } from '../models/field-definition.model';
import { RoleRule } from '../models/role-rule.model';
import { RecordShare } from '../models/record-share.model';
import { UserRole } from '../models/user-role.model';
import { Knex } from 'knex';
// Define actions
export type Action = 'read' | 'create' | 'update' | 'delete' | 'share';
// Define subjects - can be string (object type key) or model class
export type Subjects = InferSubjects<any> | 'all';
export type AppAbility = Ability<[Action, Subjects]>;
@Injectable()
export class AbilityFactory {
/**
* Build CASL Ability for a user
* Rules come from 3 layers:
* 1. Global object rules (from object_definitions + object_fields)
* 2. Role rules (from role_rules)
* 3. Share rules (from record_shares for this user)
*/
async buildForUser(user: User, knex: Knex): Promise<AppAbility> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility as any,
);
// 1. Load global object rules
await this.addGlobalRules(user, knex, can, cannot);
// 2. Load role rules
await this.addRoleRules(user, knex, can);
// 3. Load share rules
await this.addShareRules(user, knex, can);
return build({
// Optional: detect subject type from instance
detectSubjectType: (item) => {
if (typeof item === 'string') return item;
return item.constructor?.name || 'unknown';
},
});
}
/**
* Add global rules from object_definitions and object_fields
*/
private async addGlobalRules(
user: User,
knex: Knex,
can: any,
cannot: any,
) {
const objectDefs = await knex<ObjectDefinition>('object_definitions').select('*');
for (const objDef of objectDefs) {
const subject = objDef.apiName;
// Handle public access
if (objDef.publicRead) {
can('read', subject);
}
if (objDef.publicCreate) {
can('create', subject);
}
if (objDef.publicUpdate) {
can('update', subject);
}
if (objDef.publicDelete) {
can('delete', subject);
}
// Handle owner-based access
if (objDef.accessModel === 'owner' || objDef.accessModel === 'mixed') {
const ownerCondition = { [objDef.ownerField]: user.id };
can('read', subject, ownerCondition);
can('update', subject, ownerCondition);
can('delete', subject, ownerCondition);
can('share', subject, ownerCondition); // Owner can share their records
}
// Load field-level permissions for this object
const fields = await knex<FieldDefinition>('field_definitions')
.where('objectDefinitionId', objDef.id)
.select('*');
// Build field lists
const readableFields = fields
.filter((f) => f.defaultReadable)
.map((f) => f.apiName);
const writableFields = fields
.filter((f) => f.defaultWritable)
.map((f) => f.apiName);
// Add field-level rules if we have field restrictions
if (fields.length > 0) {
// For read, limit to readable fields
if (readableFields.length > 0) {
can('read', subject, readableFields);
}
// For update/create, limit to writable fields
if (writableFields.length > 0) {
can(['update', 'create'], subject, writableFields);
}
}
}
}
/**
* Add role-based rules from role_rules
*/
private async addRoleRules(user: User, knex: Knex, can: any) {
// Get user's roles
const userRoles = await knex<UserRole>('user_roles')
.where('userId', user.id)
.select('roleId');
if (userRoles.length === 0) return;
const roleIds = userRoles.map((ur) => ur.roleId);
// Get all role rules for these roles
const roleRules = await knex<RoleRule>('role_rules')
.whereIn('roleId', roleIds)
.select('*');
for (const roleRule of roleRules) {
// Parse and add each rule from the JSON
const rules = roleRule.rulesJson;
if (Array.isArray(rules)) {
rules.forEach((rule) => {
if (rule.inverted) {
// Handle "cannot" rules
// CASL format: { action, subject, conditions?, fields?, inverted: true }
// We'd need to properly parse this - for now, skip inverted rules in factory
} else {
// Handle "can" rules
const { action, subject, conditions, fields } = rule;
if (fields && fields.length > 0) {
can(action, subject, fields, conditions);
} else if (conditions) {
can(action, subject, conditions);
} else {
can(action, subject);
}
}
});
}
}
}
/**
* Add per-record sharing rules from record_shares
*/
private async addShareRules(user: User, knex: Knex, can: any) {
const now = new Date();
// Get all active shares for this user (grantee)
const shares = await knex<RecordShare>('record_shares')
.where('granteeUserId', user.id)
.whereNull('revokedAt')
.where(function () {
this.whereNull('expiresAt').orWhere('expiresAt', '>', now);
})
.select('*');
// Also need to join with object_definitions to get the apiName (subject)
const sharesWithObjects = await knex('record_shares')
.join('object_definitions', 'record_shares.objectDefinitionId', 'object_definitions.id')
.where('record_shares.granteeUserId', user.id)
.whereNull('record_shares.revokedAt')
.where(function () {
this.whereNull('record_shares.expiresAt').orWhere('record_shares.expiresAt', '>', now);
})
.select(
'record_shares.*',
'object_definitions.apiName as objectApiName',
);
for (const share of sharesWithObjects) {
const subject = share.objectApiName;
const actions = Array.isArray(share.actions) ? share.actions : JSON.parse(share.actions);
const fields = share.fields ? (Array.isArray(share.fields) ? share.fields : JSON.parse(share.fields)) : null;
// Create condition: record must match the shared recordId
const condition = { id: share.recordId };
for (const action of actions) {
if (fields && fields.length > 0) {
// Field-scoped share
can(action, subject, fields, condition);
} else {
// Full record share
can(action, subject, condition);
}
}
}
}
}

View File

@@ -5,6 +5,7 @@ import {
UnauthorizedException,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { AuthService } from './auth.service';
@@ -40,17 +41,33 @@ class RegisterDto {
export class AuthController {
constructor(private authService: AuthService) {}
private isCentralSubdomain(subdomain: string): boolean {
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
return centralSubdomains.includes(subdomain);
}
@HttpCode(HttpStatus.OK)
@Post('login')
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
async login(
@TenantId() tenantId: string,
@Body() loginDto: LoginDto,
@Req() req: any,
) {
const subdomain = req.raw?.subdomain;
// If it's a central subdomain, tenantId is not required
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
}
const user = await this.authService.validateUser(
tenantId,
loginDto.email,
loginDto.password,
subdomain,
);
if (!user) {
@@ -64,9 +81,15 @@ export class AuthController {
async register(
@TenantId() tenantId: string,
@Body() registerDto: RegisterDto,
@Req() req: any,
) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
const subdomain = req.raw?.subdomain;
// If it's a central subdomain, tenantId is not required
if (!subdomain || !this.isCentralSubdomain(subdomain)) {
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
}
const user = await this.authService.register(
@@ -75,8 +98,17 @@ export class AuthController {
registerDto.password,
registerDto.firstName,
registerDto.lastName,
subdomain,
);
return user;
}
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout() {
// For stateless JWT, logout is handled on client-side
// This endpoint exists for consistency and potential future enhancements
return { message: 'Logged out successfully' };
}
}

View File

@@ -5,10 +5,14 @@ import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { TenantModule } from '../tenant/tenant.module';
import { AbilityFactory } from './ability.factory';
import { AbilitiesGuard } from './guards/abilities.guard';
@Module({
imports: [
PassportModule,
TenantModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
@@ -17,8 +21,8 @@ import { JwtStrategy } from './jwt.strategy';
}),
}),
],
providers: [AuthService, JwtStrategy],
providers: [AuthService, JwtStrategy, AbilityFactory, AbilitiesGuard],
controllers: [AuthController],
exports: [AuthService],
exports: [AuthService, AbilityFactory, AbilitiesGuard],
})
export class AuthModule {}

View File

@@ -1,48 +1,82 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { getCentralPrisma } from '../prisma/central-prisma.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private tenantDbService: TenantDatabaseService,
private jwtService: JwtService,
) {}
private isCentralSubdomain(subdomain: string): boolean {
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
return centralSubdomains.includes(subdomain);
}
async validateUser(
tenantId: string,
email: string,
password: string,
subdomain?: string,
): Promise<any> {
const user = await this.prisma.user.findUnique({
where: {
tenantId_email: {
tenantId,
email,
},
},
include: {
tenant: true,
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: true,
},
},
},
},
},
},
},
// Check if this is a central subdomain
if (subdomain && this.isCentralSubdomain(subdomain)) {
return this.validateCentralUser(email, password);
}
// Otherwise, validate as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const user = await tenantDb('users')
.where({ email })
.first();
if (!user) {
return null;
}
if (await bcrypt.compare(password, user.password)) {
// Load user roles and permissions
const userRoles = await tenantDb('user_roles')
.where({ userId: user.id })
.join('roles', 'user_roles.roleId', 'roles.id')
.select('roles.*');
const { password: _, ...result } = user;
return {
...result,
tenantId,
userRoles,
};
}
return null;
}
private async validateCentralUser(
email: string,
password: string,
): Promise<any> {
const centralPrisma = getCentralPrisma();
const user = await centralPrisma.user.findUnique({
where: { email },
});
if (user && (await bcrypt.compare(password, user.password))) {
const { password, ...result } = user;
return result;
if (!user) {
return null;
}
if (await bcrypt.compare(password, user.password)) {
const { password: _, ...result } = user;
return {
...result,
isCentralAdmin: true,
};
}
return null;
@@ -52,7 +86,6 @@ export class AuthService {
const payload = {
sub: user.id,
email: user.email,
tenantId: user.tenantId,
};
return {
@@ -62,7 +95,6 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
tenantId: user.tenantId,
},
};
}
@@ -73,16 +105,53 @@ export class AuthService {
password: string,
firstName?: string,
lastName?: string,
subdomain?: string,
) {
// Check if this is a central subdomain
if (subdomain && this.isCentralSubdomain(subdomain)) {
return this.registerCentralUser(email, password, firstName, lastName);
}
// Otherwise, register as tenant user
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
const hashedPassword = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({
const [userId] = await tenantDb('users').insert({
email,
password: hashedPassword,
firstName,
lastName,
isActive: true,
created_at: new Date(),
updated_at: new Date(),
});
const user = await tenantDb('users')
.where({ id: userId })
.first();
const { password: _, ...result } = user;
return result;
}
private async registerCentralUser(
email: string,
password: string,
firstName?: string,
lastName?: string,
) {
const centralPrisma = getCentralPrisma();
const hashedPassword = await bcrypt.hash(password, 10);
const user = await centralPrisma.user.create({
data: {
tenantId,
email,
password: hashedPassword,
firstName,
lastName,
firstName: firstName || null,
lastName: lastName || null,
isActive: true,
},
});

View File

@@ -0,0 +1,24 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AppAbility } from '../ability.factory';
/**
* Decorator to inject the current user's ability into a route handler
* Usage: @CurrentAbility() ability: AppAbility
*/
export const CurrentAbility = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AppAbility => {
const request = ctx.switchToHttp().getRequest();
return request.ability;
},
);
/**
* Decorator to inject the current user into a route handler
* Usage: @CurrentUser() user: User
*/
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
import { Action } from '../ability.factory';
import { CHECK_ABILITY_KEY, RequiredRule } from '../guards/abilities.guard';
/**
* Decorator to check abilities
* Usage: @CheckAbility({ action: 'read', subject: 'Post' })
*/
export const CheckAbility = (...rules: RequiredRule[]) =>
SetMetadata(CHECK_ABILITY_KEY, rules);

View File

@@ -0,0 +1,51 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Action, AppAbility } from '../ability.factory';
export interface RequiredRule {
action: Action;
subject: string;
}
/**
* Key for metadata
*/
export const CHECK_ABILITY_KEY = 'check_ability';
/**
* Guard that checks CASL abilities
* Use with @CheckAbility() decorator
*/
@Injectable()
export class AbilitiesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const rules = this.reflector.get<RequiredRule[]>(
CHECK_ABILITY_KEY,
context.getHandler(),
) || [];
if (rules.length === 0) {
return true; // No rules specified, allow
}
const request = context.switchToHttp().getRequest();
const ability: AppAbility = request.ability;
if (!ability) {
throw new ForbiddenException('Ability not found on request');
}
// Check all rules
for (const rule of rules) {
if (!ability.can(rule.action, rule.subject)) {
throw new ForbiddenException(
`You don't have permission to ${rule.action} ${rule.subject}`,
);
}
}
return true;
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable, NestMiddleware, Inject } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { AbilityFactory } from '../ability.factory';
import { Knex } from 'knex';
/**
* Middleware to build and attach CASL ability to request
* Must run after authentication middleware
*/
@Injectable()
export class AbilityMiddleware implements NestMiddleware {
constructor(
private readonly abilityFactory: AbilityFactory,
@Inject('KnexConnection') private readonly knex: Knex,
) {}
async use(req: Request & { user?: any; ability?: any }, res: Response, next: NextFunction) {
if (req.user) {
// Build ability for authenticated user
req.ability = await this.abilityFactory.buildForUser(req.user, this.knex);
}
next();
}
}

View File

@@ -0,0 +1,145 @@
import { QueryBuilder, Model } from 'objection';
import { User } from '../models/user.model';
import { ObjectDefinition } from '../models/object-definition.model';
import { Knex } from 'knex';
/**
* Query scoping utilities for authorization
* Apply SQL-level filtering to ensure users only see records they have access to
*/
export interface AuthScopeOptions {
user: User;
objectDefinition: ObjectDefinition;
action: 'read' | 'update' | 'delete';
knex: Knex;
}
/**
* Apply authorization scope to a query builder
* This implements the SQL equivalent of the CASL ability checks
*
* Rules:
* 1. If object is public_{action} => allow all
* 2. If object is owner/mixed => allow owned OR shared
*/
export function applyAuthScope<M extends Model>(
query: QueryBuilder<M, M[]>,
options: AuthScopeOptions,
): QueryBuilder<M, M[]> {
const { user, objectDefinition, action, knex } = options;
// If public access for this action, no restrictions
if (
(action === 'read' && objectDefinition.publicRead) ||
(action === 'update' && objectDefinition.publicUpdate) ||
(action === 'delete' && objectDefinition.publicDelete)
) {
return query;
}
// Otherwise, apply owner + share logic
const ownerField = objectDefinition.ownerField || 'ownerId';
const tableName = query.modelClass().tableName;
return query.where((builder) => {
// Owner condition
builder.where(`${tableName}.${ownerField}`, user.id);
// OR shared condition
builder.orWhereExists((subquery) => {
subquery
.from('record_shares')
.join('object_definitions', 'record_shares.object_definition_id', 'object_definitions.id')
.whereRaw('record_shares.record_id = ??', [`${tableName}.id`])
.where('record_shares.grantee_user_id', user.id)
.where('object_definitions.id', objectDefinition.id)
.whereNull('record_shares.revoked_at')
.where(function () {
this.whereNull('record_shares.expires_at')
.orWhere('record_shares.expires_at', '>', knex.fn.now());
})
.whereRaw("JSON_CONTAINS(record_shares.actions, ?)", [JSON.stringify(action)]);
});
});
}
/**
* Apply read scope - most common use case
*/
export function applyReadScope<M extends Model>(
query: QueryBuilder<M, M[]>,
user: User,
objectDefinition: ObjectDefinition,
knex: Knex,
): QueryBuilder<M, M[]> {
return applyAuthScope(query, { user, objectDefinition, action: 'read', knex });
}
/**
* Apply update scope
*/
export function applyUpdateScope<M extends Model>(
query: QueryBuilder<M, M[]>,
user: User,
objectDefinition: ObjectDefinition,
knex: Knex,
): QueryBuilder<M, M[]> {
return applyAuthScope(query, { user, objectDefinition, action: 'update', knex });
}
/**
* Apply delete scope
*/
export function applyDeleteScope<M extends Model>(
query: QueryBuilder<M, M[]>,
user: User,
objectDefinition: ObjectDefinition,
knex: Knex,
): QueryBuilder<M, M[]> {
return applyAuthScope(query, { user, objectDefinition, action: 'delete', knex });
}
/**
* Check if user can access a specific record
* This is for single-record operations
*/
export async function canAccessRecord(
recordId: string,
user: User,
objectDefinition: ObjectDefinition,
action: 'read' | 'update' | 'delete',
knex: Knex,
): Promise<boolean> {
// If public access for this action
if (
(action === 'read' && objectDefinition.publicRead) ||
(action === 'update' && objectDefinition.publicUpdate) ||
(action === 'delete' && objectDefinition.publicDelete)
) {
return true;
}
const ownerField = objectDefinition.ownerField || 'ownerId';
// Check if user owns the record (we need the table name, which we can't easily get here)
// This function is meant to be used with a fetched record
// For now, we'll check shares only
// Check if there's a valid share
const now = new Date();
const share = await knex('record_shares')
.where({
objectDefinitionId: objectDefinition.id,
recordId: recordId,
granteeUserId: user.id,
})
.whereNull('revokedAt')
.where(function () {
this.whereNull('expiresAt').orWhere('expiresAt', '>', now);
})
.whereRaw("JSON_CONTAINS(actions, ?)", [JSON.stringify(action)])
.first();
return !!share;
}

View File

@@ -0,0 +1,306 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Knex } from 'knex';
export interface CustomMigrationRecord {
id: string;
tenantId: string;
name: string;
description: string;
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
sql: string;
status: 'pending' | 'executed' | 'failed';
executedAt?: Date;
error?: string;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class CustomMigrationService {
private readonly logger = new Logger(CustomMigrationService.name);
/**
* Generate SQL to create a table with standard fields
*/
generateCreateTableSQL(
tableName: string,
fields: {
apiName: string;
type: string;
isRequired?: boolean;
isUnique?: boolean;
defaultValue?: string;
}[] = [],
): string {
// Start with standard fields
const columns: string[] = [
'`id` VARCHAR(36) PRIMARY KEY',
'`ownerId` VARCHAR(36)',
'`name` VARCHAR(255)',
'`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
'`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
];
// Add custom fields
for (const field of fields) {
const column = this.fieldToColumn(field);
columns.push(column);
}
// Add foreign key and index for ownerId
columns.push('INDEX `idx_owner` (`ownerId`)');
return `CREATE TABLE IF NOT EXISTS \`${tableName}\` (
${columns.join(',\n ')}
)`;
}
/**
* Convert field definition to SQL column definition
*/
private fieldToColumn(field: {
apiName: string;
type: string;
isRequired?: boolean;
isUnique?: boolean;
defaultValue?: string;
}): string {
const columnName = field.apiName;
let columnDef = `\`${columnName}\``;
// Map field types to SQL types
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
columnDef += ' VARCHAR(255)';
break;
case 'LONG_TEXT':
columnDef += ' LONGTEXT';
break;
case 'NUMBER':
case 'DECIMAL':
columnDef += ' DECIMAL(18, 2)';
break;
case 'INTEGER':
columnDef += ' INT';
break;
case 'BOOLEAN':
columnDef += ' BOOLEAN DEFAULT FALSE';
break;
case 'DATE':
columnDef += ' DATE';
break;
case 'DATE_TIME':
columnDef += ' DATETIME';
break;
case 'EMAIL':
columnDef += ' VARCHAR(255)';
break;
case 'URL':
columnDef += ' VARCHAR(2048)';
break;
case 'PHONE':
columnDef += ' VARCHAR(20)';
break;
case 'CURRENCY':
columnDef += ' DECIMAL(18, 2)';
break;
case 'PERCENT':
columnDef += ' DECIMAL(5, 2)';
break;
case 'PICKLIST':
case 'MULTI_PICKLIST':
columnDef += ' VARCHAR(255)';
break;
case 'LOOKUP':
case 'BELONGS_TO':
columnDef += ' VARCHAR(36)';
break;
default:
columnDef += ' VARCHAR(255)';
}
// Add constraints
if (field.isRequired) {
columnDef += ' NOT NULL';
} else {
columnDef += ' NULL';
}
if (field.isUnique) {
columnDef += ' UNIQUE';
}
if (field.defaultValue !== undefined && field.defaultValue !== null) {
columnDef += ` DEFAULT '${field.defaultValue}'`;
}
return columnDef;
}
/**
* Create a custom migration record in the database
*/
async createMigrationRecord(
tenantKnex: Knex,
data: {
tenantId: string;
name: string;
description: string;
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
sql: string;
},
): Promise<CustomMigrationRecord> {
// Ensure custom_migrations table exists
await this.ensureMigrationsTable(tenantKnex);
const id = require('crypto').randomUUID();
const now = new Date();
await tenantKnex('custom_migrations').insert({
id,
tenantId: data.tenantId,
name: data.name,
description: data.description,
type: data.type,
sql: data.sql,
status: 'pending',
created_at: now,
updated_at: now,
});
return tenantKnex('custom_migrations').where({ id }).first();
}
/**
* Execute a pending migration and update its status
*/
async executeMigration(
tenantKnex: Knex,
migrationId: string,
): Promise<CustomMigrationRecord> {
try {
// Get the migration record
const migration = await tenantKnex('custom_migrations')
.where({ id: migrationId })
.first();
if (!migration) {
throw new Error(`Migration ${migrationId} not found`);
}
if (migration.status === 'executed') {
this.logger.log(`Migration ${migrationId} already executed`);
return migration;
}
// Execute the SQL
this.logger.log(`Executing migration: ${migration.name}`);
await tenantKnex.raw(migration.sql);
// Update status
const now = new Date();
await tenantKnex('custom_migrations')
.where({ id: migrationId })
.update({
status: 'executed',
executedAt: now,
updated_at: now,
});
this.logger.log(`Migration ${migration.name} executed successfully`);
return tenantKnex('custom_migrations').where({ id: migrationId }).first();
} catch (error) {
this.logger.error(`Failed to execute migration ${migrationId}:`, error);
// Update status with error
const now = new Date();
await tenantKnex('custom_migrations')
.where({ id: migrationId })
.update({
status: 'failed',
error: error.message,
updated_at: now,
});
throw error;
}
}
/**
* Create and execute a migration in one step
*/
async createAndExecuteMigration(
tenantKnex: Knex,
tenantId: string,
data: {
name: string;
description: string;
type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom';
sql: string;
},
): Promise<CustomMigrationRecord> {
// Create the migration record
const migration = await this.createMigrationRecord(tenantKnex, {
tenantId,
...data,
});
// Execute it immediately
return this.executeMigration(tenantKnex, migration.id);
}
/**
* Ensure the custom_migrations table exists in the tenant database
*/
private async ensureMigrationsTable(tenantKnex: Knex): Promise<void> {
const hasTable = await tenantKnex.schema.hasTable('custom_migrations');
if (!hasTable) {
await tenantKnex.schema.createTable('custom_migrations', (table) => {
table.uuid('id').primary();
table.uuid('tenantId').notNullable();
table.string('name', 255).notNullable();
table.text('description');
table.enum('type', ['create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom']).notNullable();
table.text('sql').notNullable();
table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending');
table.timestamp('executedAt').nullable();
table.text('error').nullable();
table.timestamps(true, true);
table.index(['tenantId']);
table.index(['status']);
table.index(['created_at']);
});
this.logger.log('Created custom_migrations table');
}
}
/**
* Get all migrations for a tenant
*/
async getMigrations(
tenantKnex: Knex,
tenantId: string,
filter?: {
status?: 'pending' | 'executed' | 'failed';
type?: string;
},
): Promise<CustomMigrationRecord[]> {
await this.ensureMigrationsTable(tenantKnex);
let query = tenantKnex('custom_migrations').where({ tenantId });
if (filter?.status) {
query = query.where({ status: filter.status });
}
if (filter?.type) {
query = query.where({ type: filter.type });
}
return query.orderBy('created_at', 'asc');
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CustomMigrationService } from './custom-migration.service';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
providers: [CustomMigrationService],
exports: [CustomMigrationService],
})
export class MigrationModule {}

View File

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

View File

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

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

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

View File

@@ -0,0 +1,114 @@
import { Model, ModelOptions, QueryContext } from 'objection';
import { randomUUID } from 'crypto';
/**
* Central database models using Objection.js
* These models work with the central database (not tenant databases)
*/
export class CentralTenant extends Model {
static tableName = 'tenants';
id: string;
name: string;
slug: string;
dbHost: string;
dbPort: number;
dbName: string;
dbUsername: string;
dbPassword: string;
status: string;
createdAt: Date;
updatedAt: Date;
// Relations
domains?: CentralDomain[];
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
// Auto-generate slug from name if not provided
if (!this.slug && this.name) {
this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
static get relationMappings() {
return {
domains: {
relation: Model.HasManyRelation,
modelClass: CentralDomain,
join: {
from: 'tenants.id',
to: 'domains.tenantId',
},
},
};
}
}
export class CentralDomain extends Model {
static tableName = 'domains';
id: string;
domain: string;
tenantId: string;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
// Relations
tenant?: CentralTenant;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
static get relationMappings() {
return {
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: CentralTenant,
join: {
from: 'domains.tenantId',
to: 'tenants.id',
},
},
};
}
}
export class CentralUser extends Model {
static tableName = 'users';
id: string;
email: string;
password: string;
firstName: string | null;
lastName: string | null;
role: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
$beforeInsert(queryContext: QueryContext) {
this.id = this.id || randomUUID();
this.createdAt = new Date();
this.updatedAt = new Date();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,81 @@
import { BaseModel } from './base.model';
export interface FieldOption {
label: string;
value: string | number | boolean;
}
export interface ValidationRule {
type: 'required' | 'min' | 'max' | 'email' | 'url' | 'pattern' | 'custom';
value?: any;
message?: string;
}
export interface UIMetadata {
// Display properties
placeholder?: string;
helpText?: string;
// View visibility
showOnList?: boolean;
showOnDetail?: boolean;
showOnEdit?: boolean;
sortable?: boolean;
// Field type specific options
options?: FieldOption[]; // For select, multi-select
rows?: number; // For textarea
min?: number; // For number, date
max?: number; // For number, date
step?: number; // For number
accept?: string; // For file/image
relationDisplayField?: string; // Which field to display for relations
// Formatting
format?: string; // Date format, number format, etc.
prefix?: string; // Currency symbol, etc.
suffix?: string;
// Validation
validationRules?: ValidationRule[];
// Advanced
dependsOn?: string[]; // Field dependencies
computedValue?: string; // Formula for computed fields
}
export class FieldDefinition extends BaseModel {
static tableName = 'field_definitions';
id!: string;
objectDefinitionId!: string;
apiName!: string;
label!: string;
type!: string;
length?: number;
precision?: number;
scale?: number;
referenceObject?: string;
defaultValue?: string;
description?: string;
isRequired!: boolean;
isUnique!: boolean;
isSystem!: boolean;
isCustom!: boolean;
displayOrder!: number;
uiMetadata?: UIMetadata;
// Field-level permissions
defaultReadable!: boolean;
defaultWritable!: boolean;
static relationMappings = {
objectDefinition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'object-definition.model',
join: {
from: 'field_definitions.objectDefinitionId',
to: 'object_definitions.id',
},
},
};
}

View File

@@ -0,0 +1,68 @@
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;
// Authorization fields
accessModel: 'public' | 'owner' | 'mixed';
publicRead: boolean;
publicCreate: boolean;
publicUpdate: boolean;
publicDelete: boolean;
ownerField: string;
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' },
accessModel: { type: 'string', enum: ['public', 'owner', 'mixed'] },
publicRead: { type: 'boolean' },
publicCreate: { type: 'boolean' },
publicUpdate: { type: 'boolean' },
publicDelete: { type: 'boolean' },
ownerField: { type: 'string' },
},
};
}
static get relationMappings() {
const { FieldDefinition } = require('./field-definition.model');
const { RecordShare } = require('./record-share.model');
return {
fields: {
relation: BaseModel.HasManyRelation,
modelClass: FieldDefinition,
join: {
from: 'object_definitions.id',
to: 'field_definitions.objectDefinitionId',
},
},
recordShares: {
relation: BaseModel.HasManyRelation,
modelClass: RecordShare,
join: {
from: 'object_definitions.id',
to: 'record_shares.objectDefinitionId',
},
},
};
}
}

View File

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

View File

@@ -0,0 +1,79 @@
import { BaseModel } from './base.model';
export class RecordShare extends BaseModel {
static tableName = 'record_shares';
id!: string;
objectDefinitionId!: string;
recordId!: string;
granteeUserId!: string;
grantedByUserId!: string;
actions!: any; // JSON field - will be string[] when parsed
fields?: any; // JSON field - will be string[] when parsed
expiresAt?: Date;
revokedAt?: Date;
createdAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['objectDefinitionId', 'recordId', 'granteeUserId', 'grantedByUserId', 'actions'],
properties: {
id: { type: 'string' },
objectDefinitionId: { type: 'string' },
recordId: { type: 'string' },
granteeUserId: { type: 'string' },
grantedByUserId: { type: 'string' },
actions: {
type: 'array',
items: { type: 'string' },
},
fields: {
type: ['array', 'null'],
items: { type: 'string' },
},
expiresAt: { type: ['string', 'null'], format: 'date-time' },
revokedAt: { type: ['string', 'null'], format: 'date-time' },
},
};
}
static get relationMappings() {
const { ObjectDefinition } = require('./object-definition.model');
const { User } = require('./user.model');
return {
objectDefinition: {
relation: BaseModel.BelongsToOneRelation,
modelClass: ObjectDefinition,
join: {
from: 'record_shares.objectDefinitionId',
to: 'object_definitions.id',
},
},
granteeUser: {
relation: BaseModel.BelongsToOneRelation,
modelClass: User,
join: {
from: 'record_shares.granteeUserId',
to: 'users.id',
},
},
grantedByUser: {
relation: BaseModel.BelongsToOneRelation,
modelClass: User,
join: {
from: 'record_shares.grantedByUserId',
to: 'users.id',
},
},
};
}
// Check if share is currently valid
isValid(): boolean {
if (this.revokedAt) return false;
if (this.expiresAt && new Date(this.expiresAt) < new Date()) return false;
return true;
}
}

View File

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

View File

@@ -0,0 +1,38 @@
import { BaseModel } from './base.model';
export class RoleRule extends BaseModel {
static tableName = 'role_rules';
id: string;
roleId: string;
rulesJson: any[]; // Array of CASL rules
createdAt: Date;
updatedAt: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['roleId', 'rulesJson'],
properties: {
id: { type: 'string' },
roleId: { type: 'string' },
rulesJson: { type: 'array' },
},
};
}
static get relationMappings() {
const { Role } = require('./role.model');
return {
role: {
relation: BaseModel.BelongsToOneRelation,
modelClass: Role,
join: {
from: 'role_rules.roleId',
to: 'roles.id',
},
},
};
}
}

View File

@@ -0,0 +1,75 @@
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');
const { RoleRule } = require('./role-rule.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',
},
},
roleRules: {
relation: BaseModel.HasManyRelation,
modelClass: RoleRule,
join: {
from: 'roles.id',
to: 'role_rules.roleId',
},
},
};
}
}

View File

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

View File

@@ -0,0 +1,74 @@
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');
const { RecordShare } = require('./record-share.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',
},
},
sharesGranted: {
relation: BaseModel.HasManyRelation,
modelClass: RecordShare,
join: {
from: 'users.id',
to: 'record_shares.grantedByUserId',
},
},
sharesReceived: {
relation: BaseModel.HasManyRelation,
modelClass: RecordShare,
join: {
from: 'users.id',
to: 'record_shares.granteeUserId',
},
},
};
}
}

View File

@@ -0,0 +1,314 @@
import { Injectable } from '@nestjs/common';
import { FieldDefinition } from '../models/field-definition.model';
export interface FieldConfigDTO {
id: string;
apiName: string;
label: string;
type: string;
placeholder?: string;
helpText?: string;
defaultValue?: any;
isRequired?: boolean;
isReadOnly?: boolean;
showOnList?: boolean;
showOnDetail?: boolean;
showOnEdit?: boolean;
sortable?: boolean;
options?: Array<{ label: string; value: any }>;
rows?: number;
min?: number;
max?: number;
step?: number;
accept?: string;
relationObject?: string;
relationDisplayField?: string;
format?: string;
prefix?: string;
suffix?: string;
validationRules?: Array<{
type: string;
value?: any;
message?: string;
}>;
dependsOn?: string[];
computedValue?: string;
}
export interface ObjectDefinitionDTO {
id: string;
apiName: string;
label: string;
pluralLabel?: string;
description?: string;
isSystem: boolean;
fields: FieldConfigDTO[];
}
@Injectable()
export class FieldMapperService {
/**
* Convert a field definition from the database to a frontend-friendly FieldConfig
*/
mapFieldToDTO(field: any): FieldConfigDTO {
// Parse ui_metadata if it's a JSON string or object
let uiMetadata: any = {};
const metadataField = field.ui_metadata || field.uiMetadata;
if (metadataField) {
if (typeof metadataField === 'string') {
try {
uiMetadata = JSON.parse(metadataField);
} catch (e) {
uiMetadata = {};
}
} else {
uiMetadata = metadataField;
}
}
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
return {
id: field.id,
apiName: field.apiName,
label: field.label,
type: frontendType,
// Display properties
placeholder: uiMetadata.placeholder || field.description,
helpText: uiMetadata.helpText || field.description,
defaultValue: field.defaultValue,
// Validation
isRequired: field.isRequired || false,
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
// View visibility
showOnList: uiMetadata.showOnList !== false,
showOnDetail: uiMetadata.showOnDetail !== false,
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
sortable: uiMetadata.sortable !== false,
// Field type specific options
options: uiMetadata.options,
rows: uiMetadata.rows,
min: uiMetadata.min,
max: uiMetadata.max,
step: uiMetadata.step,
accept: uiMetadata.accept,
relationObject: field.referenceObject,
// For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
// Formatting
format: uiMetadata.format,
prefix: uiMetadata.prefix,
suffix: uiMetadata.suffix,
// Validation rules
validationRules: this.buildValidationRules(field, uiMetadata),
// Advanced
dependsOn: uiMetadata.dependsOn,
computedValue: uiMetadata.computedValue,
};
}
/**
* Map database field type to frontend FieldType enum
*/
private mapFieldType(dbType: string): string {
const typeMap: Record<string, string> = {
'string': 'text',
'text': 'textarea',
'integer': 'number',
'decimal': 'number',
'boolean': 'boolean',
'date': 'date',
'datetime': 'datetime',
'time': 'time',
'email': 'email',
'url': 'url',
'phone': 'text',
'picklist': 'select',
'multipicklist': 'multiSelect',
'lookup': 'belongsTo',
'master-detail': 'belongsTo',
'currency': 'currency',
'percent': 'number',
'textarea': 'textarea',
'richtext': 'markdown',
'file': 'file',
'image': 'image',
'json': 'json',
};
return typeMap[dbType.toLowerCase()] || 'text';
}
/**
* Build validation rules array
*/
private buildValidationRules(field: any, uiMetadata: any): Array<any> {
const rules = uiMetadata.validationRules || [];
// Add required rule if field is required and not already in rules
if (field.isRequired && !rules.some(r => r.type === 'required')) {
rules.unshift({
type: 'required',
message: `${field.label} is required`,
});
}
// Add length validation for string fields
if (field.length && field.type === 'string') {
rules.push({
type: 'max',
value: field.length,
message: `${field.label} must not exceed ${field.length} characters`,
});
}
// Add email validation
if (field.type === 'email' && !rules.some(r => r.type === 'email')) {
rules.push({
type: 'email',
message: `${field.label} must be a valid email address`,
});
}
// Add URL validation
if (field.type === 'url' && !rules.some(r => r.type === 'url')) {
rules.push({
type: 'url',
message: `${field.label} must be a valid URL`,
});
}
return rules;
}
/**
* Convert object definition with fields to DTO
*/
mapObjectDefinitionToDTO(objectDef: any): ObjectDefinitionDTO {
return {
id: objectDef.id,
apiName: objectDef.apiName,
label: objectDef.label,
pluralLabel: objectDef.pluralLabel,
description: objectDef.description,
isSystem: objectDef.isSystem || false,
fields: (objectDef.fields || [])
.filter((f: any) => f.isActive !== false)
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map((f: any) => this.mapFieldToDTO(f)),
};
}
/**
* Generate default UI metadata for a field type
*/
generateDefaultUIMetadata(fieldType: string): any {
const defaults: Record<string, any> = {
text: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
textarea: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
rows: 4,
},
number: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
currency: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
prefix: '$',
step: 0.01,
},
boolean: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
},
date: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
format: 'yyyy-MM-dd',
},
datetime: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: true,
format: 'yyyy-MM-dd HH:mm:ss',
},
email: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
validationRules: [{ type: 'email' }],
},
url: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
validationRules: [{ type: 'url' }],
},
select: {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
options: [],
},
multiSelect: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
options: [],
},
image: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
accept: 'image/*',
},
file: {
showOnList: false,
showOnDetail: true,
showOnEdit: true,
sortable: false,
},
};
return defaults[fieldType] || {
showOnList: true,
showOnDetail: true,
showOnEdit: true,
sortable: true,
};
}
}

View File

@@ -0,0 +1,35 @@
import { Model } from 'objection';
/**
* Base model for all dynamic and system models
* Provides common functionality for all objects
*/
export class BaseModel extends Model {
// Common fields
id?: string;
tenantId?: string;
ownerId?: string;
name?: string;
created_at?: Date;
updated_at?: Date;
// Hook to set system-managed fields
$beforeInsert() {
// created_at and updated_at are handled by the database
// ownerId should be set by the controller/service
}
$beforeUpdate() {
// updated_at is handled by the database
}
/**
* Get the API name for this object
* Override in subclasses
*/
static get objectApiName(): string {
return 'BaseModel';
}
}

View File

@@ -0,0 +1,219 @@
import { randomUUID } from 'crypto';
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
import { BaseModel } from './base.model';
export interface FieldDefinition {
apiName: string;
label: string;
type: string;
isRequired?: boolean;
isUnique?: boolean;
referenceObject?: string;
defaultValue?: string;
}
export interface RelationDefinition {
name: string;
type: 'belongsTo' | 'hasMany' | 'hasManyThrough';
targetObjectApiName: string;
fromColumn: string;
toColumn: string;
}
export interface ObjectMetadata {
apiName: string;
tableName: string;
fields: FieldDefinition[];
relations?: RelationDefinition[];
}
export class DynamicModelFactory {
/**
* Create a dynamic model class from object metadata
* @param meta Object metadata
* @param getModel Function to retrieve model classes from registry
*/
static createModel(
meta: ObjectMetadata,
getModel?: (apiName: string) => ModelClass<any>,
): ModelClass<any> {
const { tableName, fields, apiName, relations = [] } = meta;
// Build JSON schema properties
const properties: Record<string, any> = {
id: { type: 'string' },
tenantId: { type: 'string' },
ownerId: { type: 'string' },
name: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' },
};
// Don't require system-managed fields (id, tenantId, ownerId, timestamps)
// These are auto-set by hooks or database
const required: string[] = [];
// Add custom fields
for (const field of fields) {
properties[field.apiName] = this.fieldToJsonSchema(field);
// Only mark as required if explicitly required AND not a system field
const systemFields = ['id', 'tenantId', 'ownerId', 'name', 'created_at', 'updated_at'];
if (field.isRequired && !systemFields.includes(field.apiName)) {
required.push(field.apiName);
}
}
// Build relation mappings from lookup fields
const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
// Store lookup fields metadata for later use
const lookupFieldsInfo = lookupFields.map(f => ({
apiName: f.apiName,
relationName: f.apiName.replace(/Id$/, '').toLowerCase(),
referenceObject: f.referenceObject,
targetTable: this.getTableName(f.referenceObject),
}));
// Create the dynamic model class extending Model directly
class DynamicModel extends Model {
id?: string;
tenantId?: string;
ownerId?: string;
name?: string;
created_at?: string;
updated_at?: string;
static tableName = tableName;
static objectApiName = apiName;
static lookupFields = lookupFieldsInfo;
static get relationMappings(): RelationMappings {
const mappings: RelationMappings = {};
// Build relation mappings from lookup fields
for (const lookupInfo of lookupFieldsInfo) {
// Use getModel function if provided, otherwise use string reference
let modelClass: any = lookupInfo.referenceObject;
if (getModel) {
const resolvedModel = getModel(lookupInfo.referenceObject);
// Only use resolved model if it exists, otherwise skip this relation
// It will be resolved later when the model is registered
if (resolvedModel) {
modelClass = resolvedModel;
} else {
// Skip this relation if model not found yet
continue;
}
}
mappings[lookupInfo.relationName] = {
relation: Model.BelongsToOneRelation,
modelClass,
join: {
from: `${tableName}.${lookupInfo.apiName}`,
to: `${lookupInfo.targetTable}.id`,
},
};
}
return mappings;
}
static get jsonSchema() {
return {
type: 'object',
required,
properties,
};
}
async $beforeInsert() {
if (!this.id) {
this.id = randomUUID();
}
if (!this.created_at) {
this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
if (!this.updated_at) {
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
}
async $beforeUpdate(opt: any, queryContext: any) {
await super.$beforeUpdate(opt, queryContext);
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
}
return DynamicModel as any;
}
/**
* Convert a field definition to JSON schema property
*/
private static fieldToJsonSchema(field: FieldDefinition): Record<string, any> {
switch (field.type.toUpperCase()) {
case 'TEXT':
case 'STRING':
case 'EMAIL':
case 'URL':
case 'PHONE':
case 'PICKLIST':
case 'MULTI_PICKLIST':
return {
type: 'string',
...(field.isUnique && { uniqueItems: true }),
};
case 'LONG_TEXT':
return { type: 'string' };
case 'NUMBER':
case 'DECIMAL':
case 'CURRENCY':
case 'PERCENT':
return {
type: 'number',
...(field.isUnique && { uniqueItems: true }),
};
case 'INTEGER':
return {
type: 'integer',
...(field.isUnique && { uniqueItems: true }),
};
case 'BOOLEAN':
return { type: 'boolean', default: false };
case 'DATE':
return { type: 'string', format: 'date' };
case 'DATE_TIME':
return { type: 'string', format: 'date-time' };
case 'LOOKUP':
case 'BELONGS_TO':
return { type: 'string' };
default:
return { type: 'string' };
}
}
/**
* Get table name from object API name
*/
private static getTableName(objectApiName: string): string {
// Convert PascalCase/camelCase to snake_case and pluralize
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
}

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { ModelClass } from 'objection';
import { BaseModel } from './base.model';
import { DynamicModelFactory, ObjectMetadata } from './dynamic-model.factory';
/**
* Registry to store and retrieve dynamic models
* One registry per tenant
*/
@Injectable()
export class ModelRegistry {
private registry = new Map<string, ModelClass<BaseModel>>();
/**
* Register a model in the registry
*/
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void {
this.registry.set(apiName, modelClass);
}
/**
* Get a model from the registry
*/
getModel(apiName: string): ModelClass<BaseModel> {
const model = this.registry.get(apiName);
if (!model) {
throw new Error(`Model for ${apiName} not found in registry`);
}
return model;
}
/**
* Check if a model exists in the registry
*/
hasModel(apiName: string): boolean {
return this.registry.has(apiName);
}
/**
* Create and register a model from metadata
*/
createAndRegisterModel(
metadata: ObjectMetadata,
): ModelClass<BaseModel> {
// Create model with a getModel function that resolves from this registry
// Returns undefined if model not found (for models not yet registered)
const model = DynamicModelFactory.createModel(
metadata,
(apiName: string) => this.registry.get(apiName),
);
this.registerModel(metadata.apiName, model);
return model;
}
/**
* Get all registered model names
*/
getAllModelNames(): string[] {
return Array.from(this.registry.keys());
}
/**
* Clear the registry (useful for testing)
*/
clear(): void {
this.registry.clear();
}
}

View File

@@ -0,0 +1,184 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Knex } from 'knex';
import { ModelClass } from 'objection';
import { BaseModel } from './base.model';
import { ModelRegistry } from './model.registry';
import { ObjectMetadata } from './dynamic-model.factory';
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
import { UserModel, RoleModel, PermissionModel } from './system-models';
/**
* Service to manage dynamic models for a specific tenant
*/
@Injectable()
export class ModelService {
private readonly logger = new Logger(ModelService.name);
private tenantRegistries = new Map<string, ModelRegistry>();
constructor(private tenantDbService: TenantDatabaseService) {}
/**
* Get or create a registry for a tenant
*/
getTenantRegistry(tenantId: string): ModelRegistry {
if (!this.tenantRegistries.has(tenantId)) {
const registry = new ModelRegistry();
// Register system models that are defined as static Objection models
this.registerSystemModels(registry);
this.tenantRegistries.set(tenantId, registry);
}
return this.tenantRegistries.get(tenantId)!;
}
/**
* Register static system models in the registry
* Uses simplified models without complex relationMappings to avoid modelPath issues
*/
private registerSystemModels(registry: ModelRegistry): void {
// Register system models by their API name (used in referenceObject fields)
// These are simplified versions without relationMappings to avoid dependency issues
registry.registerModel('User', UserModel as any);
registry.registerModel('Role', RoleModel as any);
registry.registerModel('Permission', PermissionModel as any);
this.logger.debug('Registered system models: User, Role, Permission');
}
/**
* Create and register a model for a tenant
*/
async createModelForObject(
tenantId: string,
objectMetadata: ObjectMetadata,
): Promise<ModelClass<BaseModel>> {
const registry = this.getTenantRegistry(tenantId);
const model = registry.createAndRegisterModel(objectMetadata);
this.logger.log(
`Registered model for ${objectMetadata.apiName} in tenant ${tenantId}`,
);
return model;
}
/**
* Get a model for a tenant and object
*/
getModel(tenantId: string, objectApiName: string): ModelClass<BaseModel> {
const registry = this.getTenantRegistry(tenantId);
return registry.getModel(objectApiName);
}
/**
* Get a bound model (with knex connection) for a tenant and object
*/
async getBoundModel(
tenantId: string,
objectApiName: string,
): Promise<ModelClass<BaseModel>> {
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const model = this.getModel(tenantId, objectApiName);
// Bind knex to the model and also to all models in the registry
// This ensures system models also have knex bound when they're used in relations
const registry = this.getTenantRegistry(tenantId);
const allModels = registry.getAllModelNames();
// Bind knex to all models to ensure relations work
for (const modelName of allModels) {
try {
const m = registry.getModel(modelName);
if (m && !m.knex()) {
m.knex(knex);
}
} catch (error) {
// Ignore errors for models that don't need binding
}
}
return model.bindKnex(knex);
}
/**
* Check if a model exists for a tenant
*/
hasModel(tenantId: string, objectApiName: string): boolean {
const registry = this.getTenantRegistry(tenantId);
return registry.hasModel(objectApiName);
}
/**
* Get all model names for a tenant
*/
getAllModelNames(tenantId: string): string[] {
const registry = this.getTenantRegistry(tenantId);
return registry.getAllModelNames();
}
/**
* Ensure a model is registered with all its dependencies.
* This method handles recursive model creation for related objects.
*
* @param tenantId - The tenant ID
* @param objectApiName - The object API name to ensure registration for
* @param fetchMetadata - Callback function to fetch object metadata (provided by ObjectService)
* @param visited - Set to track visited models and prevent infinite loops
*/
async ensureModelWithDependencies(
tenantId: string,
objectApiName: string,
fetchMetadata: (apiName: string) => Promise<ObjectMetadata>,
visited: Set<string> = new Set(),
): Promise<void> {
// Prevent infinite recursion
if (visited.has(objectApiName)) {
return;
}
visited.add(objectApiName);
// Check if model already exists
if (this.hasModel(tenantId, objectApiName)) {
return;
}
try {
// Fetch the object metadata
const objectMetadata = await fetchMetadata(objectApiName);
// Extract lookup fields to find dependencies
const lookupFields = objectMetadata.fields.filter(
f => f.type === 'LOOKUP' && f.referenceObject
);
// Recursively ensure all dependent models are registered first
for (const field of lookupFields) {
if (field.referenceObject) {
try {
await this.ensureModelWithDependencies(
tenantId,
field.referenceObject,
fetchMetadata,
visited,
);
} catch (error) {
// If related object doesn't exist (e.g., system tables), skip it
this.logger.debug(
`Skipping registration of related model ${field.referenceObject}: ${error.message}`
);
}
}
}
// Now create and register this model (all dependencies are ready)
await this.createModelForObject(tenantId, objectMetadata);
this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`);
} catch (error) {
this.logger.warn(
`Failed to ensure model for ${objectApiName}: ${error.message}`
);
throw error;
}
}
}

View File

@@ -0,0 +1,85 @@
import { Model } from 'objection';
/**
* Simplified User model for use in dynamic object relations
* This version doesn't include complex relationMappings to avoid modelPath issues
*/
export class UserModel extends Model {
static tableName = 'users';
static objectApiName = 'User';
id!: string;
email!: string;
firstName?: string;
lastName?: string;
name?: string;
isActive!: boolean;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['email'],
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
firstName: { type: 'string' },
lastName: { type: 'string' },
name: { type: 'string' },
isActive: { type: 'boolean' },
},
};
}
// No relationMappings to avoid modelPath resolution issues
// These simplified models are only used for lookup relations from dynamic models
}
/**
* Simplified Role model for use in dynamic object relations
*/
export class RoleModel extends Model {
static tableName = 'roles';
static objectApiName = 'Role';
id!: string;
name!: string;
description?: string;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
};
}
}
/**
* Simplified Permission model for use in dynamic object relations
*/
export class PermissionModel extends Model {
static tableName = 'permissions';
static objectApiName = 'Permission';
id!: string;
name!: string;
description?: string;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
};
}
}

View File

@@ -2,10 +2,23 @@ import { Module } from '@nestjs/common';
import { ObjectService } from './object.service';
import { RuntimeObjectController } from './runtime-object.controller';
import { SetupObjectController } from './setup-object.controller';
import { SchemaManagementService } from './schema-management.service';
import { FieldMapperService } from './field-mapper.service';
import { TenantModule } from '../tenant/tenant.module';
import { MigrationModule } from '../migration/migration.module';
import { ModelRegistry } from './models/model.registry';
import { ModelService } from './models/model.service';
@Module({
providers: [ObjectService],
imports: [TenantModule, MigrationModule],
providers: [
ObjectService,
SchemaManagementService,
FieldMapperService,
ModelRegistry,
ModelService,
],
controllers: [RuntimeObjectController, SetupObjectController],
exports: [ObjectService],
exports: [ObjectService, SchemaManagementService, FieldMapperService, ModelService],
})
export class ObjectModule {}

View File

@@ -1,42 +1,79 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CustomMigrationService } from '../migration/custom-migration.service';
import { ModelService } from './models/model.service';
import { ObjectMetadata } from './models/dynamic-model.factory';
import { applyReadScope, applyUpdateScope, applyDeleteScope } from '../auth/query-scope.util';
import { User } from '../models/user.model';
import { ObjectDefinition } from '../models/object-definition.model';
@Injectable()
export class ObjectService {
constructor(private prisma: PrismaService) {}
private readonly logger = new Logger(ObjectService.name);
constructor(
private tenantDbService: TenantDatabaseService,
private customMigrationService: CustomMigrationService,
private modelService: ModelService,
) {}
// Setup endpoints - Object metadata management
async getObjectDefinitions(tenantId: string) {
return this.prisma.objectDefinition.findMany({
where: { tenantId },
include: {
fields: true,
},
orderBy: { label: 'asc' },
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const objects = await knex('object_definitions')
.select('object_definitions.*')
.orderBy('label', 'asc');
// Fetch app information for objects that have app_id
for (const obj of objects) {
if (obj.app_id) {
const app = await knex('apps')
.where({ id: obj.app_id })
.select('id', 'slug', 'label', 'description')
.first();
obj.app = app;
}
}
return objects;
}
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 resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
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');
// Normalize all fields to ensure system fields are properly marked
const normalizedFields = fields.map((field: any) => this.normalizeField(field));
// Get app information if object belongs to an app
let app = null;
if (obj.app_id) {
app = await knex('apps')
.where({ id: obj.app_id })
.select('id', 'slug', 'label', 'description')
.first();
}
return {
...obj,
fields: normalizedFields,
app,
};
}
async createObjectDefinition(
@@ -49,15 +86,149 @@ export class ObjectService {
isSystem?: boolean;
},
) {
return this.prisma.objectDefinition.create({
data: {
tenantId,
...data,
tableName: `custom_${data.apiName.toLowerCase()}`,
},
// Resolve tenant ID in case a slug was passed
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Generate UUID for the new object
const objectId = require('crypto').randomUUID();
// Create the object definition record
await knex('object_definitions').insert({
id: objectId,
...data,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const objectDef = await knex('object_definitions').where({ id: objectId }).first();
// Create standard field definitions (only if they don't already exist)
const standardFields = [
{
apiName: 'ownerId',
label: 'Owner',
type: 'LOOKUP',
description: 'The user who owns this record',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: 'User',
isSystem: true,
isCustom: false,
},
{
apiName: 'name',
label: 'Name',
type: 'STRING',
description: 'The primary name field for this record',
isRequired: false, // Optional field
isUnique: false,
referenceObject: null,
isSystem: false,
isCustom: false,
},
{
apiName: 'created_at',
label: 'Created At',
type: 'DATE_TIME',
description: 'The timestamp when this record was created',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: null,
isSystem: true,
isCustom: false,
},
{
apiName: 'updated_at',
label: 'Updated At',
type: 'DATE_TIME',
description: 'The timestamp when this record was last updated',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: null,
isSystem: true,
isCustom: false,
},
];
// Insert standard field definitions that don't already exist
for (const field of standardFields) {
const existingField = await knex('field_definitions')
.where({
objectDefinitionId: objectDef.id,
apiName: field.apiName,
})
.first();
if (!existingField) {
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: objectDef.id,
...field,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
// For lookup fields, set ui_metadata with relationDisplayField
if (field.type === 'LOOKUP') {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: 'name',
});
}
await knex('field_definitions').insert(fieldData);
}
}
// Create a migration to create the table
const tableName = this.getTableName(data.apiName);
const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName);
try {
await this.customMigrationService.createAndExecuteMigration(
knex,
resolvedTenantId,
{
name: `create_${tableName}_table`,
description: `Create table for ${data.label} object`,
type: 'create_table',
sql: createTableSQL,
},
);
} catch (error) {
// Log the error but don't fail - migration is recorded for future retry
console.error(`Failed to execute table creation migration: ${error.message}`);
}
// Create and register the Objection model for this object
try {
const allFields = await knex('field_definitions')
.where({ objectDefinitionId: objectDef.id })
.select('apiName', 'label', 'type', 'isRequired', 'isUnique', 'referenceObject');
const objectMetadata: ObjectMetadata = {
apiName: data.apiName,
tableName,
fields: allFields.map((f: any) => ({
apiName: f.apiName,
label: f.label,
type: f.type,
isRequired: f.isRequired,
isUnique: f.isUnique,
referenceObject: f.referenceObject,
})),
relations: [],
};
await this.modelService.createModelForObject(resolvedTenantId, objectMetadata);
} catch (error) {
console.error(`Failed to create model for object ${data.apiName}:`, error.message);
}
return objectDef;
}
async createFieldDefinition(
tenantId: string,
objectApiName: string,
@@ -68,20 +239,225 @@ export class ObjectService {
description?: string;
isRequired?: boolean;
isUnique?: boolean;
isLookup?: boolean;
referenceTo?: string;
referenceObject?: string;
relationObject?: string;
relationDisplayField?: string;
defaultValue?: string;
options?: any;
},
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const obj = await this.getObjectDefinition(tenantId, objectApiName);
return this.prisma.fieldDefinition.create({
data: {
objectId: obj.id,
...data,
},
});
// Convert frontend type to database type
const dbFieldType = this.convertFrontendFieldType(data.type);
// Use relationObject if provided (alias for referenceObject)
const referenceObject = data.referenceObject || data.relationObject;
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: obj.id,
apiName: data.apiName,
label: data.label,
type: dbFieldType,
description: data.description,
isRequired: data.isRequired ?? false,
isUnique: data.isUnique ?? false,
referenceObject: referenceObject,
defaultValue: data.defaultValue,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
});
}
const [id] = await knex('field_definitions').insert(fieldData);
return knex('field_definitions').where({ id }).first();
}
// Helper to get table name from object definition
private getTableName(objectApiName: string): string {
// Convert CamelCase to snake_case and pluralize
// Account -> accounts, ContactPerson -> contact_persons
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (can be enhanced)
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase;
} else {
return snakeCase + 's';
}
}
/**
* Normalize field definition to ensure system fields are properly marked
*/
private normalizeField(field: any): any {
const systemFieldNames = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt'];
const isSystemField = systemFieldNames.includes(field.apiName);
return {
...field,
// Ensure system fields are marked correctly
isSystem: isSystemField ? true : field.isSystem,
isRequired: isSystemField ? false : field.isRequired,
isCustom: isSystemField ? false : field.isCustom ?? true,
};
}
/**
* Convert frontend field type to database field type
*/
private convertFrontendFieldType(frontendType: string): string {
const typeMap: Record<string, string> = {
'text': 'TEXT',
'textarea': 'LONG_TEXT',
'password': 'TEXT',
'email': 'EMAIL',
'number': 'NUMBER',
'currency': 'CURRENCY',
'percent': 'PERCENT',
'select': 'PICKLIST',
'multiSelect': 'MULTI_PICKLIST',
'boolean': 'BOOLEAN',
'date': 'DATE',
'datetime': 'DATE_TIME',
'time': 'TIME',
'url': 'URL',
'color': 'TEXT',
'json': 'JSON',
'belongsTo': 'LOOKUP',
'hasMany': 'LOOKUP',
'manyToMany': 'LOOKUP',
'markdown': 'LONG_TEXT',
'code': 'LONG_TEXT',
'file': 'FILE',
'image': 'IMAGE',
};
return typeMap[frontendType] || 'TEXT';
}
/**
* Filter incoming data to only include writable fields based on field definitions
* Removes system fields and fields that don't exist in the schema
*/
private async filterWritableFields(
tenantId: string,
objectApiName: string,
data: any,
isUpdate: boolean = false,
): Promise<any> {
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const filtered: any = {};
for (const [key, value] of Object.entries(data)) {
// Find the field definition
const fieldDef = objectDef.fields.find((f: any) => f.apiName === key);
if (!fieldDef) {
// Field doesn't exist in schema, skip it
this.logger.warn(`Field ${key} not found in ${objectApiName} schema, skipping`);
continue;
}
// Skip system fields
if (fieldDef.isSystem) {
this.logger.debug(`Skipping system field ${key}`);
continue;
}
// Check if field is writable (for authorization)
// Support both snake_case (from DB) and camelCase (if mapped)
const defaultWritable = fieldDef.default_writable ?? fieldDef.defaultWritable;
if (defaultWritable === false || defaultWritable === 0) {
this.logger.warn(`Field ${key} is not writable (default_writable = ${defaultWritable}), skipping`);
continue;
}
// For update operations, also skip ID field
if (isUpdate && key === 'id') {
continue;
}
// Field is valid and writable, include it
filtered[key] = value;
}
return filtered;
}
/**
* Ensure a model is registered for the given object.
* Delegates to ModelService which handles creating the model and all its dependencies.
*/
private async ensureModelRegistered(
tenantId: string,
objectApiName: string,
): Promise<void> {
// Provide a metadata fetcher function that the ModelService can use
const fetchMetadata = async (apiName: string): Promise<ObjectMetadata> => {
const objectDef = await this.getObjectDefinition(tenantId, apiName);
const tableName = this.getTableName(apiName);
// Build relations from lookup fields, but only for models that exist
const lookupFields = objectDef.fields.filter((f: any) =>
f.type === 'LOOKUP' && f.referenceObject
);
// Filter to only include relations where we can successfully resolve the target
const validRelations: any[] = [];
for (const field of lookupFields) {
// Check if the referenced object will be available
// We'll let the recursive registration attempt it, but won't include failed ones
validRelations.push({
name: field.apiName.replace(/Id$/, '').toLowerCase(),
type: 'belongsTo' as const,
targetObjectApiName: field.referenceObject,
fromColumn: field.apiName,
toColumn: 'id',
});
}
return {
apiName,
tableName,
fields: objectDef.fields.map((f: any) => ({
apiName: f.apiName,
label: f.label,
type: f.type,
isRequired: f.isRequired,
isUnique: f.isUnique,
referenceObject: f.referenceObject,
})),
relations: validRelations,
};
};
// Let the ModelService handle recursive model creation
try {
await this.modelService.ensureModelWithDependencies(
tenantId,
objectApiName,
fetchMetadata,
);
} catch (error) {
this.logger.warn(
`Failed to ensure model for ${objectApiName}: ${error.message}. Will fall back to manual hydration.`,
);
}
}
// Runtime endpoints - CRUD operations
@@ -91,20 +467,127 @@ export class ObjectService {
userId: string,
filters?: any,
) {
// For demonstration, using Account as example static object
if (objectApiName === 'Account') {
return this.prisma.account.findMany({
where: {
tenantId,
ownerId: userId, // Basic sharing rule
...filters,
},
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Apply authorization scoping
query = applyReadScope(query, user, objectDefModel, knex);
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Apply additional filters
if (filters) {
query = query.where(filters);
}
return query.select('*');
}
} catch (error) {
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// For custom objects, you'd need dynamic query building
// This is a simplified version
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet
let query = knex(tableName);
// Add ownership filter if ownerId field exists (basic fallback)
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ [`${tableName}.ownerId`]: userId });
}
// Apply additional filters
if (filters) {
query = query.where(filters);
}
// Get base records
const records = await query.select(`${tableName}.*`);
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0 && records.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
// Get unique IDs to fetch
const relatedIds = [...new Set(
records
.map(r => r[field.apiName])
.filter(Boolean)
)];
if (relatedIds.length > 0) {
// Fetch all related records in one query
const relatedRecords = await knex(relatedTable)
.whereIn('id', relatedIds)
.select('*');
// Create a map for quick lookup
const relatedMap = new Map(
relatedRecords.map(r => [r.id, r])
);
// Attach related records to main records
for (const record of records) {
const relatedId = record[field.apiName];
if (relatedId && relatedMap.has(relatedId)) {
record[relationName] = relatedMap.get(relatedId);
}
}
}
}
}
return records;
}
async getRecord(
@@ -113,23 +596,109 @@ export class ObjectService {
recordId: string,
userId: string,
) {
if (objectApiName === 'Account') {
const record = await this.prisma.account.findFirst({
where: {
id: recordId,
tenantId,
ownerId: userId,
},
});
if (!record) {
throw new NotFoundException('Record not found');
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId });
// Apply authorization scoping
query = applyReadScope(query, user, objectDefModel, knex);
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found or you do not have access');
}
return record;
}
return record;
} catch (error) {
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
// Add ownership filter if ownerId field exists (basic fallback)
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ [`${tableName}.ownerId`]: userId });
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found or you do not have access');
}
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
const relatedId = record[field.apiName];
if (relatedId) {
// Fetch the related record
const relatedRecord = await knex(relatedTable)
.where({ id: relatedId })
.first();
if (relatedRecord) {
record[relationName] = relatedRecord;
}
}
}
}
return record;
}
async createRecord(
@@ -138,17 +707,76 @@ export class ObjectService {
data: any,
userId: string,
) {
if (objectApiName === 'Account') {
return this.prisma.account.create({
data: {
tenantId,
ownerId: userId,
...data,
},
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Check create permission
if (!objectDefModel.publicCreate) {
// Get user with roles to check role-based permissions
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
// TODO: Check role-based create permissions from role_rules
// For now, only allow if publicCreate is true
throw new ForbiddenException('You do not have permission to create records for this object');
}
// Filter data to only include writable fields based on field definitions
// Do this BEFORE model registration so both Objection and fallback paths use clean data
const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, false);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const recordData = {
...allowedData,
ownerId: userId, // Auto-set owner
};
const record = await boundModel.query().insert(recordData);
return record;
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
}
// Fallback to raw Knex if model not available
const tableName = this.getTableName(objectApiName);
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
const recordData: any = {
id: knex.raw('(UUID())'),
...allowedData, // Use filtered data instead of raw data
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
if (hasOwner) {
recordData.ownerId = userId;
}
const [id] = await knex(tableName).insert(recordData);
return knex(tableName).where({ id }).first();
}
async updateRecord(
@@ -158,17 +786,70 @@ export class ObjectService {
data: any,
userId: string,
) {
if (objectApiName === 'Account') {
// Verify ownership
await this.getRecord(tenantId, objectApiName, recordId, userId);
return this.prisma.account.update({
where: { id: recordId },
data,
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
// Filter data to only include writable fields based on field definitions
// Do this BEFORE authorization checks so both paths use clean data
const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, true);
// Verify user has access to read the record first (using authorization scope)
const tableName = this.getTableName(objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let checkQuery = boundModel.query().where({ id: recordId });
checkQuery = applyUpdateScope(checkQuery, user, objectDefModel, knex);
const existingRecord = await checkQuery.first();
if (!existingRecord) {
throw new ForbiddenException('You do not have permission to update this record');
}
this.logger.log(`[UPDATE] Record ID: ${recordId}, Type: ${typeof recordId}`);
this.logger.log(`[UPDATE] Existing record ID: ${existingRecord.id}, Type: ${typeof existingRecord.id}`);
this.logger.log(`[UPDATE] Allowed data:`, JSON.stringify(allowedData));
const numUpdated = await boundModel.query().where({ id: recordId }).update(allowedData);
this.logger.log(`[UPDATE] Number of records updated: ${numUpdated}`);
const updatedRecord = await boundModel.query().where({ id: recordId }).first();
this.logger.log(`[UPDATE] Updated record:`, updatedRecord ? 'found' : 'NOT FOUND');
return updatedRecord;
}
// Fallback to raw Knex with basic ownership check
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner && !objectDefModel.publicUpdate) {
const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first();
if (!record) {
throw new ForbiddenException('You do not have permission to update this record');
}
}
await knex(tableName)
.where({ id: recordId })
.update({ ...allowedData, updated_at: knex.fn.now() }); // Use filtered data
return knex(tableName).where({ id: recordId }).first();
}
async deleteRecord(
@@ -177,15 +858,56 @@ export class ObjectService {
recordId: string,
userId: string,
) {
if (objectApiName === 'Account') {
// Verify ownership
await this.getRecord(tenantId, objectApiName, recordId, userId);
return this.prisma.account.delete({
where: { id: recordId },
});
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
const tableName = this.getTableName(objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
// Try to use the Objection model if available
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
// Check if user has permission to delete this record
let checkQuery = boundModel.query().where({ id: recordId });
checkQuery = applyDeleteScope(checkQuery, user, objectDefModel, knex);
const existingRecord = await checkQuery.first();
if (!existingRecord) {
throw new ForbiddenException('You do not have permission to delete this record');
}
await boundModel.query().where({ id: recordId }).delete();
return { success: true };
}
// Fallback to raw Knex with basic ownership check
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner && !objectDefModel.publicDelete) {
const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first();
if (!record) {
throw new ForbiddenException('You do not have permission to delete this record');
}
}
await knex(tableName).where({ id: recordId }).delete();
return { success: true };
}
}

View File

@@ -0,0 +1,216 @@
import { Injectable, Logger } from '@nestjs/common';
import type { 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;
}
}

View File

@@ -2,18 +2,28 @@ import {
Controller,
Get,
Post,
Put,
Param,
Body,
UseGuards,
Inject,
} from '@nestjs/common';
import { ObjectService } from './object.service';
import { FieldMapperService } from './field-mapper.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { ObjectDefinition } from '../models/object-definition.model';
import { FieldDefinition } from '../models/field-definition.model';
import { Knex } from 'knex';
@Controller('setup/objects')
@UseGuards(JwtAuthGuard)
export class SetupObjectController {
constructor(private objectService: ObjectService) {}
constructor(
private objectService: ObjectService,
private fieldMapperService: FieldMapperService,
@Inject('KnexConnection') private readonly knex: Knex,
) {}
@Get()
async getObjectDefinitions(@TenantId() tenantId: string) {
@@ -25,7 +35,20 @@ export class SetupObjectController {
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
return this.objectService.getObjectDefinition(tenantId, objectApiName);
const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
}
@Get(':objectApiName/ui-config')
async getObjectUIConfig(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
const objectDef = await this.objectService.getObjectDefinition(
tenantId,
objectApiName,
);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
}
@Post()
@@ -42,10 +65,130 @@ export class SetupObjectController {
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
return this.objectService.createFieldDefinition(
const field = await this.objectService.createFieldDefinition(
tenantId,
objectApiName,
data,
);
// Map the created field to frontend format
return this.fieldMapperService.mapFieldToDTO(field);
}
// Access & Permissions endpoints
/**
* Get object access configuration
*/
@Get(':objectApiName/access')
async getAccess(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
const objectDef = await ObjectDefinition.query(this.knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
if (!objectDef) {
throw new Error('Object definition not found');
}
return {
accessModel: objectDef.accessModel,
publicRead: objectDef.publicRead,
publicCreate: objectDef.publicCreate,
publicUpdate: objectDef.publicUpdate,
publicDelete: objectDef.publicDelete,
ownerField: objectDef.ownerField,
fields: objectDef['fields'] || [],
};
}
/**
* Update object access configuration
*/
@Put(':objectApiName/access')
async updateAccess(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() dto: any,
) {
console.log('dto', JSON.stringify(dto));
const objectDef = await ObjectDefinition.query(this.knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object definition not found');
}
return ObjectDefinition.query(this.knex).patchAndFetchById(objectDef.id, dto);
}
/**
* Create or update field-level permissions
*/
@Post(':objectApiName/fields/:fieldKey/permissions')
async setFieldPermissions(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Param('fieldKey') fieldKey: string,
@Body() dto: any,
) {
const objectDef = await ObjectDefinition.query(this.knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object definition not found');
}
// Find the field definition
const field = await FieldDefinition.query(this.knex)
.findOne({
objectDefinitionId: objectDef.id,
apiName: fieldKey,
});
if (!field) {
throw new Error('Field definition not found');
}
// Update field permissions
return FieldDefinition.query(this.knex).patchAndFetchById(field.id, {
defaultReadable: dto.defaultReadable ?? field.defaultReadable,
defaultWritable: dto.defaultWritable ?? field.defaultWritable,
});
}
/**
* Bulk set field permissions for an object
*/
@Put(':objectApiName/field-permissions')
async bulkSetFieldPermissions(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() fields: { fieldKey: string; defaultReadable: boolean; defaultWritable: boolean }[],
) {
const objectDef = await ObjectDefinition.query(this.knex)
.findOne({ apiName: objectApiName });
if (!objectDef) {
throw new Error('Object definition not found');
}
// Update each field in the field_definitions table
for (const fieldUpdate of fields) {
await FieldDefinition.query(this.knex)
.where({
objectDefinitionId: objectDef.id,
apiName: fieldUpdate.fieldKey,
})
.patch({
defaultReadable: fieldUpdate.defaultReadable,
defaultWritable: fieldUpdate.defaultWritable,
});
}
return { success: true };
}
}

View File

@@ -0,0 +1,54 @@
import { IsString, IsUUID, IsBoolean, IsOptional, IsObject } from 'class-validator';
export class CreatePageLayoutDto {
@IsString()
name: string;
@IsUUID()
objectId: string;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsObject()
layoutConfig: {
fields: Array<{
fieldId: string;
x: number;
y: number;
w: number;
h: number;
}>;
};
@IsString()
@IsOptional()
description?: string;
}
export class UpdatePageLayoutDto {
@IsString()
@IsOptional()
name?: string;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsObject()
@IsOptional()
layoutConfig?: {
fields: Array<{
fieldId: string;
x: number;
y: number;
w: number;
h: number;
}>;
};
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,55 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Query,
} from '@nestjs/common';
import { PageLayoutService } from './page-layout.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
@Controller('page-layouts')
@UseGuards(JwtAuthGuard)
export class PageLayoutController {
constructor(private readonly pageLayoutService: PageLayoutService) {}
@Post()
create(@TenantId() tenantId: string, @Body() createPageLayoutDto: CreatePageLayoutDto) {
return this.pageLayoutService.create(tenantId, createPageLayoutDto);
}
@Get()
findAll(@TenantId() tenantId: string, @Query('objectId') objectId?: string) {
return this.pageLayoutService.findAll(tenantId, objectId);
}
@Get('default/:objectId')
findDefaultByObject(@TenantId() tenantId: string, @Param('objectId') objectId: string) {
return this.pageLayoutService.findDefaultByObject(tenantId, objectId);
}
@Get(':id')
findOne(@TenantId() tenantId: string, @Param('id') id: string) {
return this.pageLayoutService.findOne(tenantId, id);
}
@Patch(':id')
update(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() updatePageLayoutDto: UpdatePageLayoutDto,
) {
return this.pageLayoutService.update(tenantId, id, updatePageLayoutDto);
}
@Delete(':id')
remove(@TenantId() tenantId: string, @Param('id') id: string) {
return this.pageLayoutService.remove(tenantId, id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PageLayoutService } from './page-layout.service';
import { PageLayoutController } from './page-layout.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [PageLayoutController],
providers: [PageLayoutService],
exports: [PageLayoutService],
})
export class PageLayoutModule {}

View File

@@ -0,0 +1,118 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CreatePageLayoutDto, UpdatePageLayoutDto } from './dto/page-layout.dto';
@Injectable()
export class PageLayoutService {
constructor(private tenantDbService: TenantDatabaseService) {}
async create(tenantId: string, createDto: CreatePageLayoutDto) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
// If this layout is set as default, unset other defaults for the same object
if (createDto.isDefault) {
await knex('page_layouts')
.where({ object_id: createDto.objectId })
.update({ is_default: false });
}
const [id] = await knex('page_layouts').insert({
name: createDto.name,
object_id: createDto.objectId,
is_default: createDto.isDefault || false,
layout_config: JSON.stringify(createDto.layoutConfig),
description: createDto.description || null,
});
// Get the inserted record
const result = await knex('page_layouts').where({ id }).first();
return result;
}
async findAll(tenantId: string, objectId?: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
let query = knex('page_layouts');
if (objectId) {
query = query.where({ object_id: objectId });
}
const layouts = await query.orderByRaw('is_default DESC, name ASC');
return layouts;
}
async findOne(tenantId: string, id: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const layout = await knex('page_layouts').where({ id }).first();
if (!layout) {
throw new NotFoundException(`Page layout with ID ${id} not found`);
}
return layout;
}
async findDefaultByObject(tenantId: string, objectId: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const layout = await knex('page_layouts')
.where({ object_id: objectId, is_default: true })
.first();
return layout || null;
}
async update(tenantId: string, id: string, updateDto: UpdatePageLayoutDto) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
// Check if layout exists
await this.findOne(tenantId, id);
// If setting as default, unset other defaults for the same object
if (updateDto.isDefault) {
const layout = await this.findOne(tenantId, id);
await knex('page_layouts')
.where({ object_id: layout.object_id })
.whereNot({ id })
.update({ is_default: false });
}
const updates: any = {};
if (updateDto.name !== undefined) {
updates.name = updateDto.name;
}
if (updateDto.isDefault !== undefined) {
updates.is_default = updateDto.isDefault;
}
if (updateDto.layoutConfig !== undefined) {
updates.layout_config = JSON.stringify(updateDto.layoutConfig);
}
if (updateDto.description !== undefined) {
updates.description = updateDto.description;
}
updates.updated_at = knex.fn.now();
await knex('page_layouts').where({ id }).update(updates);
// Get the updated record
const result = await knex('page_layouts').where({ id }).first();
return result;
}
async remove(tenantId: string, id: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
await this.findOne(tenantId, id);
await knex('page_layouts').where({ id }).delete();
return { message: 'Page layout deleted successfully' };
}
}

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';
import { RbacService } from './rbac.service';
import { ShareController } from './share.controller';
import { RoleController, RoleRuleController } from './role.controller';
import { UserController } from './user.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
providers: [RbacService],
controllers: [ShareController, RoleController, RoleRuleController, UserController],
exports: [RbacService],
})
export class RbacModule {}

View File

@@ -0,0 +1,137 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Inject,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Role } from '../models/role.model';
import { RoleRule } from '../models/role-rule.model';
import { Knex } from 'knex';
export class CreateRoleDto {
name: string;
guardName?: string;
description?: string;
}
export class UpdateRoleDto {
name?: string;
description?: string;
}
export class CreateRoleRuleDto {
roleId: string;
rulesJson: any[]; // Array of CASL rules
}
export class UpdateRoleRuleDto {
rulesJson: any[];
}
@Controller('roles')
@UseGuards(JwtAuthGuard)
export class RoleController {
constructor(@Inject('KnexConnection') private readonly knex: Knex) {}
/**
* List all roles
*/
@Get()
async list() {
return Role.query(this.knex).withGraphFetched('[roleRules]');
}
/**
* Get a single role by ID
*/
@Get(':id')
async get(@Param('id') id: string) {
return Role.query(this.knex)
.findById(id)
.withGraphFetched('[roleRules, permissions]');
}
/**
* Create a new role
*/
@Post()
async create(@Body() createDto: CreateRoleDto) {
return Role.query(this.knex).insert({
name: createDto.name,
guardName: createDto.guardName || 'api',
description: createDto.description,
});
}
/**
* Update a role
*/
@Put(':id')
async update(@Param('id') id: string, @Body() updateDto: UpdateRoleDto) {
return Role.query(this.knex).patchAndFetchById(id, updateDto);
}
/**
* Delete a role
*/
@Delete(':id')
async delete(@Param('id') id: string) {
await Role.query(this.knex).deleteById(id);
return { success: true };
}
}
@Controller('role-rules')
@UseGuards(JwtAuthGuard)
export class RoleRuleController {
constructor(@Inject('KnexConnection') private readonly knex: Knex) {}
/**
* Get rules for a role
*/
@Get('role/:roleId')
async getForRole(@Param('roleId') roleId: string) {
return RoleRule.query(this.knex).where('roleId', roleId);
}
/**
* Create or update role rules
* This will replace existing rules for the role
*/
@Post()
async createOrUpdate(@Body() dto: CreateRoleRuleDto) {
// Delete existing rules for this role
await RoleRule.query(this.knex).where('roleId', dto.roleId).delete();
// Insert new rules
return RoleRule.query(this.knex).insert({
roleId: dto.roleId,
rulesJson: dto.rulesJson,
});
}
/**
* Update role rules by ID
*/
@Put(':id')
async update(@Param('id') id: string, @Body() dto: UpdateRoleRuleDto) {
return RoleRule.query(this.knex).patchAndFetchById(id, {
rulesJson: dto.rulesJson,
});
}
/**
* Delete role rules
*/
@Delete(':id')
async delete(@Param('id') id: string) {
await RoleRule.query(this.knex).deleteById(id);
return { success: true };
}
}

View File

@@ -0,0 +1,243 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
export class CreateShareDto {
@IsString()
objectApiName: string;
@IsString()
recordId: string;
@IsString()
granteeUserId: string;
@IsArray()
@IsString({ each: true })
actions: string[]; // ["read"], ["read", "update"], etc.
@IsOptional()
@IsArray()
@IsString({ each: true })
fields?: string[]; // Optional field scoping
@IsOptional()
@IsDateString()
expiresAt?: string;
}
export class UpdateShareDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
actions?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
fields?: string[];
@IsOptional()
@IsDateString()
expiresAt?: string;
}
@Controller('rbac/shares')
@UseGuards(JwtAuthGuard)
export class ShareController {
constructor(private tenantDbService: TenantDatabaseService) {}
/**
* Create a new share
* Only the owner (or users with share permission) can share a record
*/
@Post()
async create(
@TenantId() tenantId: string,
@CurrentUser() currentUser: any,
@Body() createDto: CreateShareDto,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition by apiName
const objectDef = await knex('object_definitions')
.where({ apiName: createDto.objectApiName })
.first();
if (!objectDef) {
throw new NotFoundException('Object definition not found');
}
// Get the table name for the object
const tableName = this.getTableName(createDto.objectApiName);
// Verify the user owns the record
const record = await knex(tableName)
.where({ id: createDto.recordId })
.first();
if (!record) {
throw new NotFoundException('Record not found');
}
if (record.ownerId !== currentUser.userId) {
throw new ForbiddenException('Only the record owner can share it');
}
// Create the share
const shareId = require('crypto').randomUUID();
await knex('record_shares').insert({
id: shareId,
object_definition_id: objectDef.id,
record_id: createDto.recordId,
grantee_user_id: createDto.granteeUserId,
granted_by_user_id: currentUser.userId,
actions: JSON.stringify(createDto.actions),
fields: createDto.fields ? JSON.stringify(createDto.fields) : null,
expires_at: createDto.expiresAt,
created_at: knex.fn.now(),
});
const share = await knex('record_shares').where({ id: shareId }).first();
return {
...share,
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
fields: share.fields ? (typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields) : null,
};
}
private getTableName(objectApiName: string): string {
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase;
} else {
return snakeCase + 's';
}
}
/**
* List shares for a specific record
* Only owner or users with access can see shares
*/
@Get(':objectApiName/:recordId')
async listForRecord(
@TenantId() tenantId: string,
@CurrentUser() currentUser: any,
@Param('objectApiName') objectApiName: string,
@Param('recordId') recordId: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get object definition
const objectDef = await knex('object_definitions')
.where({ apiName: objectApiName })
.first();
if (!objectDef) {
throw new NotFoundException('Object definition not found');
}
// Get shares for this record
const shares = await knex('record_shares')
.where({
object_definition_id: objectDef.id,
record_id: recordId,
})
.whereNull('revoked_at')
.select('*');
// Fetch user details for each share
const sharesWithUsers = await Promise.all(
shares.map(async (share: any) => {
const granteeUser = await knex('users')
.where({ id: share.grantee_user_id })
.select('id', 'email', 'firstName', 'lastName', 'name')
.first();
const grantedByUser = await knex('users')
.where({ id: share.granted_by_user_id })
.select('id', 'email', 'firstName', 'lastName', 'name')
.first();
return {
id: share.id,
recordId: share.record_id,
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
fields: share.fields ? (typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields) : null,
expiresAt: share.expires_at,
createdAt: share.created_at,
granteeUser: {
id: granteeUser.id,
email: granteeUser.email,
name: granteeUser.firstName && granteeUser.lastName
? `${granteeUser.firstName} ${granteeUser.lastName}`
: granteeUser.name || granteeUser.email,
},
grantedByUser: {
id: grantedByUser.id,
email: grantedByUser.email,
name: grantedByUser.firstName && grantedByUser.lastName
? `${grantedByUser.firstName} ${grantedByUser.lastName}`
: grantedByUser.name || grantedByUser.email,
},
};
})
);
return sharesWithUsers;
}
/**
* Revoke a share (soft delete)
*/
@Delete(':id')
async revoke(
@TenantId() tenantId: string,
@CurrentUser() currentUser: any,
@Param('id') id: string,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const share = await knex('record_shares').where({ id }).first();
if (!share) {
throw new NotFoundException('Share not found');
}
// Only the grantor can revoke
if (share.granted_by_user_id !== currentUser.userId) {
throw new ForbiddenException('Unauthorized');
}
await knex('record_shares')
.where({ id })
.update({ revoked_at: knex.fn.now() });
return { success: true };
}
}

View File

@@ -0,0 +1,41 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { User } from '../models/user.model';
@Controller('rbac/users')
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private tenantDbService: TenantDatabaseService) {}
@Get()
async getUsers(
@TenantId() tenantId: string,
@CurrentUser() currentUser: any,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get all active users from tenant database (excluding current user)
let query = User.query(knex)
.select('id', 'email', 'firstName', 'lastName')
.where('isActive', true);
// Exclude current user if we have their ID
if (currentUser?.userId) {
query = query.whereNot('id', currentUser.userId);
}
const users = await query;
return users.map((user) => ({
id: user.id,
email: user.email,
name: user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.email,
}));
}
}

View File

@@ -0,0 +1,368 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
UnauthorizedException,
Req,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
import { getCentralKnex, initCentralModels } from './central-database.service';
import { TenantProvisioningService } from './tenant-provisioning.service';
import { TenantDatabaseService } from './tenant-database.service';
import * as bcrypt from 'bcrypt';
/**
* Controller for managing central database entities (tenants, domains, users)
* Only accessible when logged in as central admin
*/
@Controller('central')
@UseGuards(JwtAuthGuard)
export class CentralAdminController {
constructor(
private readonly provisioningService: TenantProvisioningService,
private readonly tenantDbService: TenantDatabaseService,
) {
// Initialize central models on controller creation
initCentralModels();
}
private checkCentralAdmin(req: any) {
const subdomain = req.raw?.subdomain;
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
if (!subdomain || !centralSubdomains.includes(subdomain)) {
throw new UnauthorizedException('This endpoint is only accessible to central administrators');
}
}
// ==================== TENANTS ====================
@Get('tenants')
async getTenants(@Req() req: any) {
this.checkCentralAdmin(req);
return CentralTenant.query().withGraphFetched('domains');
}
@Get('tenants/:id')
async getTenant(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
return CentralTenant.query()
.findById(id)
.withGraphFetched('domains');
}
@Post('tenants')
async createTenant(
@Req() req: any,
@Body() data: {
name: string;
slug?: string;
primaryDomain: string;
dbHost?: string;
dbPort?: number;
},
) {
this.checkCentralAdmin(req);
// Use the provisioning service to create tenant with database and migrations
const result = await this.provisioningService.provisionTenant({
name: data.name,
slug: data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
primaryDomain: data.primaryDomain,
dbHost: data.dbHost,
dbPort: data.dbPort,
});
// Return the created tenant
return CentralTenant.query()
.findById(result.tenantId)
.withGraphFetched('domains');
}
@Put('tenants/:id')
async updateTenant(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
name?: string;
slug?: string;
dbHost?: string;
dbPort?: number;
dbName?: string;
dbUsername?: string;
status?: string;
},
) {
this.checkCentralAdmin(req);
return CentralTenant.query()
.patchAndFetchById(id, data);
}
@Delete('tenants/:id')
async deleteTenant(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
await CentralTenant.query().deleteById(id);
return { success: true };
}
// Get users for a specific tenant
@Get('tenants/:id/users')
async getTenantUsers(@Req() req: any, @Param('id') tenantId: string) {
this.checkCentralAdmin(req);
try {
// Get tenant to verify it exists
const tenant = await CentralTenant.query().findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Tenant not found');
}
// Connect to tenant database using tenant ID directly
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
// Fetch users from tenant database
const users = await tenantKnex('users').select('*');
// Remove password from response
return users.map(({ password, ...user }) => user);
} catch (error) {
console.error('Error fetching tenant users:', error);
throw error;
}
}
// Create a user in a specific tenant
@Post('tenants/:id/users')
async createTenantUser(
@Req() req: any,
@Param('id') tenantId: string,
@Body() data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
},
) {
this.checkCentralAdmin(req);
try {
// Get tenant to verify it exists
const tenant = await CentralTenant.query().findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Tenant not found');
}
// Connect to tenant database using tenant ID directly
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
// Hash password
const hashedPassword = await bcrypt.hash(data.password, 10);
// Generate UUID for the new user
const userId = require('crypto').randomUUID();
// Create user in tenant database
await tenantKnex('users').insert({
id: userId,
email: data.email,
password: hashedPassword,
firstName: data.firstName || null,
lastName: data.lastName || null,
created_at: new Date(),
updated_at: new Date(),
});
// Fetch and return the created user
const user = await tenantKnex('users').where('id', userId).first();
if (!user) {
throw new Error('Failed to create user');
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
} catch (error) {
console.error('Error creating tenant user:', error);
throw error;
}
}
// ==================== DOMAINS ====================
@Get('domains')
async getDomains(
@Req() req: any,
@Query('parentId') parentId?: string,
@Query('tenantId') tenantId?: string,
) {
this.checkCentralAdmin(req);
let query = CentralDomain.query().withGraphFetched('tenant');
// Filter by parent/tenant ID if provided (for related lists)
if (parentId || tenantId) {
query = query.where('tenantId', parentId || tenantId);
}
return query;
}
@Get('domains/:id')
async getDomain(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
return CentralDomain.query()
.findById(id)
.withGraphFetched('tenant');
}
@Post('domains')
async createDomain(
@Req() req: any,
@Body() data: {
domain: string;
tenantId: string;
isPrimary?: boolean;
},
) {
this.checkCentralAdmin(req);
return CentralDomain.query().insert({
domain: data.domain,
tenantId: data.tenantId,
isPrimary: data.isPrimary || false,
});
}
@Put('domains/:id')
async updateDomain(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
domain?: string;
tenantId?: string;
isPrimary?: boolean;
},
) {
this.checkCentralAdmin(req);
return CentralDomain.query()
.patchAndFetchById(id, data);
}
@Delete('domains/:id')
async deleteDomain(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
// Get domain info before deleting to invalidate cache
const domain = await CentralDomain.query().findById(id);
// Delete the domain
await CentralDomain.query().deleteById(id);
// Invalidate tenant connection cache for this domain
if (domain) {
this.tenantDbService.removeTenantConnection(domain.domain);
}
return { success: true };
}
// ==================== USERS (Central Admin Users) ====================
@Get('users')
async getUsers(@Req() req: any) {
this.checkCentralAdmin(req);
const users = await CentralUser.query();
// Remove password from response
return users.map(({ password, ...user }) => user);
}
@Get('users/:id')
async getUser(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
const user = await CentralUser.query().findById(id);
if (!user) {
throw new UnauthorizedException('User not found');
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Post('users')
async createUser(
@Req() req: any,
@Body() data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
role?: string;
isActive?: boolean;
},
) {
this.checkCentralAdmin(req);
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await CentralUser.query().insert({
email: data.email,
password: hashedPassword,
firstName: data.firstName || null,
lastName: data.lastName || null,
role: data.role || 'admin',
isActive: data.isActive !== undefined ? data.isActive : true,
});
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Put('users/:id')
async updateUser(
@Req() req: any,
@Param('id') id: string,
@Body() data: {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
role?: string;
isActive?: boolean;
},
) {
this.checkCentralAdmin(req);
const updateData: any = { ...data };
// Hash password if provided
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
} else {
// Remove password from update if not provided
delete updateData.password;
}
const user = await CentralUser.query()
.patchAndFetchById(id, updateData);
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
@Delete('users/:id')
async deleteUser(@Req() req: any, @Param('id') id: string) {
this.checkCentralAdmin(req);
await CentralUser.query().deleteById(id);
return { success: true };
}
}

View File

@@ -0,0 +1,44 @@
import Knex from 'knex';
import type { Knex as KnexType } from 'knex';
import { Model } from 'objection';
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
let centralKnex: KnexType | null = null;
/**
* Get or create a Knex instance for the central database
* This is used for Objection models that work with central entities
*/
export function getCentralKnex(): KnexType {
if (!centralKnex) {
const centralDbUrl = process.env.CENTRAL_DATABASE_URL;
if (!centralDbUrl) {
throw new Error('CENTRAL_DATABASE_URL environment variable is not set');
}
centralKnex = Knex({
client: 'mysql2',
connection: centralDbUrl,
pool: {
min: 2,
max: 10,
},
});
// Bind Objection models to this knex instance
Model.knex(centralKnex);
}
return centralKnex;
}
/**
* Initialize central models with the knex instance
*/
export function initCentralModels() {
const knex = getCentralKnex();
CentralTenant.knex(knex);
CentralDomain.knex(knex);
CentralUser.knex(knex);
}

View File

@@ -0,0 +1,245 @@
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();
/**
* Get tenant database connection by domain (for subdomain-based authentication)
* This is used when users log in via tenant subdomains
*/
async getTenantKnexByDomain(domain: string): Promise<Knex> {
const cacheKey = `domain:${domain}`;
// Check if we have a cached connection
if (this.tenantConnections.has(cacheKey)) {
// Validate the domain still exists before returning cached connection
const centralPrisma = getCentralPrisma();
try {
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
});
// If domain no longer exists, remove cached connection
if (!domainRecord) {
this.logger.warn(`Domain ${domain} no longer exists, removing cached connection`);
await this.disconnectTenant(cacheKey);
throw new Error(`Domain ${domain} not found`);
}
} catch (error) {
// If domain doesn't exist, remove from cache and re-throw
if (error.message.includes('not found')) {
throw error;
}
// For other errors, log but continue with cached connection
this.logger.warn(`Error validating domain ${domain}:`, error.message);
}
return this.tenantConnections.get(cacheKey);
}
const centralPrisma = getCentralPrisma();
// Find tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: true },
});
if (!domainRecord) {
throw new Error(`Domain ${domain} not found`);
}
const tenant = domainRecord.tenant;
this.logger.log(`Found tenant by domain: ${domain} -> ${tenant.name}`);
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenant.name} is not active`);
}
// Create connection and cache it
const tenantKnex = await this.createTenantConnection(tenant);
this.tenantConnections.set(cacheKey, tenantKnex);
return tenantKnex;
}
/**
* Get tenant database connection by tenant ID (for central admin operations)
* This is used when central admin needs to access tenant databases
*/
async getTenantKnexById(tenantId: string): Promise<Knex> {
const cacheKey = `id:${tenantId}`;
// Check if we have a cached connection (no validation needed for ID-based lookups)
if (this.tenantConnections.has(cacheKey)) {
return this.tenantConnections.get(cacheKey);
}
const centralPrisma = getCentralPrisma();
// Find tenant by ID
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 ${tenant.name} is not active`);
}
this.logger.log(`Connecting to tenant database by ID: ${tenant.name}`);
// Create connection and cache it
const tenantKnex = await this.createTenantConnection(tenant);
this.tenantConnections.set(cacheKey, tenantKnex);
return tenantKnex;
}
/**
* Legacy method - delegates to domain-based lookup
* @deprecated Use getTenantKnexByDomain or getTenantKnexById instead
*/
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
// Assume it's a domain if it contains a dot
return this.getTenantKnexByDomain(tenantIdOrSlug);
}
/**
* Create a new Knex connection to a tenant database
*/
private async createTenantConnection(tenant: any): Promise<Knex> {
// 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;
}
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;
}
/**
* Resolve tenant by ID or slug
* Tries ID first, then falls back to slug
*/
async resolveTenantId(idOrSlug: string): Promise<string> {
const centralPrisma = getCentralPrisma();
// Try by ID first
let tenant = await centralPrisma.tenant.findUnique({
where: { id: idOrSlug },
});
// If not found, try by slug
if (!tenant) {
tenant = await centralPrisma.tenant.findUnique({
where: { slug: idOrSlug },
});
}
if (!tenant) {
throw new Error(`Tenant ${idOrSlug} not found`);
}
if (tenant.status !== 'active') {
throw new Error(`Tenant ${tenant.name} is not active`);
}
return tenant.id;
}
async disconnectTenant(tenantId: string) {
const connection = this.tenantConnections.get(tenantId);
if (connection) {
await connection.destroy();
this.tenantConnections.delete(tenantId);
this.logger.log(`Disconnected tenant: ${tenantId}`);
}
}
removeTenantConnection(tenantId: string) {
this.tenantConnections.delete(tenantId);
this.logger.log(`Removed tenant connection from cache: ${tenantId}`);
}
async disconnectAll() {
for (const [tenantId, connection] of this.tenantConnections.entries()) {
await connection.destroy();
}
this.tenantConnections.clear();
this.logger.log('Disconnected all tenant connections');
}
encryptPassword(password: string): string {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(password, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
private decryptPassword(encryptedPassword: string): string {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}

View File

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

View File

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

View File

@@ -1,16 +1,134 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { TenantDatabaseService } from './tenant-database.service';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const tenantId = req.headers['x-tenant-id'] as string;
if (tenantId) {
// Attach tenantId to request object
(req as any).tenantId = tenantId;
private readonly logger = new Logger(TenantMiddleware.name);
constructor(private readonly tenantDbService: TenantDatabaseService) {}
async use(
req: FastifyRequest['raw'],
res: FastifyReply['raw'],
next: () => void,
) {
try {
// Extract subdomain from hostname
const host = req.headers.host || '';
const hostname = host.split(':')[0]; // Remove port if present
// Check Origin header to get frontend subdomain (for API calls)
const origin = req.headers.origin as string;
const referer = req.headers.referer as string;
let parts = hostname.split('.');
this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`);
// For local development, accept x-tenant-id header
let tenantId = req.headers['x-tenant-id'] as string;
let subdomain: string | null = null;
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
// Try to extract subdomain from Origin header first (for API calls from frontend)
if (origin) {
try {
const originUrl = new URL(origin);
const originHost = originUrl.hostname;
parts = originHost.split('.');
this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`);
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
} else if (referer && !tenantId) {
// Fallback to Referer if no Origin
try {
const refererUrl = new URL(referer);
const refererHost = refererUrl.hostname;
parts = refererHost.split('.');
this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`);
} catch (error) {
this.logger.warn(`Failed to parse referer: ${referer}`);
}
}
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
// For production domains with 3+ parts, extract first part as subdomain
if (parts.length >= 3) {
subdomain = parts[0];
// Ignore www subdomain
if (subdomain === 'www') {
subdomain = null;
}
}
// For development (e.g., tenant1.localhost), also check 2 parts
else if (parts.length === 2 && parts[1] === 'localhost') {
subdomain = parts[0];
}
this.logger.log(`Extracted subdomain: ${subdomain}`);
// Always attach subdomain to request if present
if (subdomain) {
(req as any).subdomain = subdomain;
}
// If x-tenant-id is explicitly provided, use it directly but still keep subdomain
if (tenantId) {
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
(req as any).tenantId = tenantId;
next();
return;
}
// Always attach subdomain to request if present
if (subdomain) {
(req as any).subdomain = subdomain;
}
// Check if this is a central subdomain
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
const isCentral = subdomain && centralSubdomains.includes(subdomain);
// If it's a central subdomain, skip tenant resolution
if (isCentral) {
this.logger.log(`Central subdomain detected: ${subdomain}, skipping tenant resolution`);
(req as any).subdomain = subdomain;
next();
return;
}
// Get tenant by subdomain if available
if (subdomain) {
try {
const tenant = await this.tenantDbService.getTenantByDomain(subdomain);
if (tenant) {
tenantId = tenant.id;
this.logger.log(
`Tenant identified: ${tenant.name} (${tenant.id}) from subdomain: ${subdomain}`,
);
}
} catch (error) {
this.logger.warn(`No tenant found for subdomain: ${subdomain}`, error.message);
// Fall back to using subdomain as tenantId directly if domain lookup fails
tenantId = subdomain;
this.logger.log(`Using subdomain as tenantId fallback: ${tenantId}`);
}
}
if (tenantId) {
// Attach tenant info to request object
(req as any).tenantId = tenantId;
} else {
this.logger.warn(`No tenant identified from host: ${hostname}`);
}
next();
} catch (error) {
this.logger.error('Error in tenant middleware', error);
next();
}
next();
}
}

View File

@@ -1,7 +1,44 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { Module, NestModule, MiddlewareConsumer, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { TenantMiddleware } from './tenant.middleware';
import { TenantDatabaseService } from './tenant-database.service';
import { TenantProvisioningService } from './tenant-provisioning.service';
import { TenantProvisioningController } from './tenant-provisioning.controller';
import { CentralAdminController } from './central-admin.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({})
@Module({
imports: [PrismaModule],
controllers: [TenantProvisioningController, CentralAdminController],
providers: [
TenantDatabaseService,
TenantProvisioningService,
TenantMiddleware,
{
provide: 'KnexConnection',
scope: Scope.REQUEST,
inject: [REQUEST, TenantDatabaseService],
useFactory: async (request: any, tenantDbService: TenantDatabaseService) => {
// Try to get subdomain first (for domain-based routing)
const subdomain = request.raw?.subdomain || request.subdomain;
const tenantId = request.raw?.tenantId || request.tenantId;
if (!subdomain && !tenantId) {
throw new Error('Neither subdomain nor tenant ID found in request');
}
// Prefer subdomain lookup (more reliable for domain-based routing)
if (subdomain) {
return await tenantDbService.getTenantKnexByDomain(subdomain);
}
// Fallback to tenant ID lookup
return await tenantDbService.getTenantKnexById(tenantId);
},
},
],
exports: [TenantDatabaseService, TenantProvisioningService, 'KnexConnection'],
})
export class TenantModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('*');

View File

@@ -0,0 +1,296 @@
# Polymorphic Record Sharing + Authorization System
This document describes the implementation of a comprehensive authorization system using CASL, Objection.js, and NestJS.
## Overview
The system supports:
- **Global object policies** - Public/private access, default permissions per object type
- **Role-based access** - Permissions assigned to roles, with CASL rule storage
- **Per-record sharing** - Polymorphic sharing where owners can grant specific users access to individual records
- **Field-level permissions** - Fine-grained control over which fields can be read/written
## Architecture
### Database Schema
#### `object_definitions` (Enhanced)
- `accessModel`: 'public' | 'owner' | 'mixed'
- `publicRead/Create/Update/Delete`: Boolean flags for public access
- `ownerField`: Field name storing record owner (default: 'ownerId')
#### `field_definitions` (Enhanced)
- `defaultReadable`: Boolean - Can this field be read by default
- `defaultWritable`: Boolean - Can this field be written by default
These permission flags are added directly to the existing `field_definitions` table, keeping all field metadata in one place.
#### `role_rules` (New)
- `roleId`: FK to roles
- `rulesJson`: JSON array of CASL rules
#### `record_shares` (New)
Polymorphic sharing table:
- `objectDefinitionId`: FK to object_definitions
- `recordId`: String (supports UUID/int)
- `granteeUserId`: User receiving access
- `grantedByUserId`: User granting access
- `actions`: JSON array of actions ["read", "update", etc.]
- `fields`: Optional JSON array of field names
- `expiresAt/revokedAt`: Optional expiry and revocation timestamps
### Backend Components
#### AbilityFactory (`src/auth/ability.factory.ts`)
Builds CASL abilities from three layers:
1. **Global rules** - From object_definitions and object_fields
2. **Role rules** - From role_rules.rulesJson
3. **Share rules** - From record_shares for the user
```typescript
const ability = await abilityFactory.buildForUser(user, knex);
if (ability.can('read', 'Post')) {
// User can read posts
}
```
#### Query Scoping (`src/auth/query-scope.util.ts`)
SQL-level filtering for list queries:
```typescript
import { applyReadScope } from '@/auth/query-scope.util';
const query = Post.query(knex);
applyReadScope(query, user, objectDefinition, knex);
// Query now only returns records user can access
```
Logic:
1. If `publicRead` is true → allow all
2. Else → owner OR valid share exists
#### Guards & Decorators
- `AbilitiesGuard` - Checks CASL abilities on routes
- `@CheckAbility()` - Decorator to require specific permissions
- `@CurrentUser()` - Inject current user
- `@CurrentAbility()` - Inject CASL ability
#### Controllers
**ShareController** (`src/rbac/share.controller.ts`)
- `POST /shares` - Create a share
- `GET /shares/record/:objectDefinitionId/:recordId` - List shares for a record
- `GET /shares/granted` - List shares granted by current user
- `GET /shares/received` - List shares received by current user
- `PATCH /shares/:id` - Update a share
- `DELETE /shares/:id` - Revoke a share
**RoleController** (`src/rbac/role.controller.ts`)
- Standard CRUD for roles
- `RoleRuleController` manages CASL rules per role
**ObjectAccessController** (`src/object/object-access.controller.ts`)
- `GET /setup/objects/:apiName/access` - Get access config
- `PUT /setup/objects/:apiName/access` - Update access model
- `POST /setup/objects/:apiName/fields/:fieldKey/permissions` - Set field permissions
- `PUT /setup/objects/:apiName/field-permissions` - Bulk update field permissions
### Frontend Components
#### ObjectAccessSettings (`components/ObjectAccessSettings.vue`)
Integrated into object management page as "Access & Permissions" tab:
- Configure access model (public/owner/mixed)
- Set public CRUD permissions
- Configure owner field
- Set default read/write permissions per field
#### RecordShareDialog (`components/RecordShareDialog.vue`)
Dialog for sharing individual records:
- List current shares
- Add new share with user email
- Select read/update permissions
- Optional field-level scoping
- Optional expiration date
- Revoke shares
#### Role Management (`pages/setup/roles.vue`)
Complete role management interface:
- List all roles
- Create new roles
- Delete roles
- Edit role permissions
#### RolePermissionsEditor (`components/RolePermissionsEditor.vue`)
Granular permission editor:
- Configure CRUD permissions per object type
- Apply conditions (e.g., "ownerId = $userId")
- Field-level restrictions (future)
## Usage Examples
### 1. Set Object to Owner-Only Access
```typescript
await api.put('/setup/objects/Post/access', {
accessModel: 'owner',
publicRead: false,
ownerField: 'ownerId'
});
```
### 2. Share a Record
```typescript
await api.post('/shares', {
objectDefinitionId: 'abc-123',
recordId: 'post-456',
granteeUserId: 'user-789',
actions: ['read', 'update'],
fields: ['title', 'body'], // Optional field scoping
expiresAt: '2025-12-31T23:59:59Z' // Optional expiry
});
```
### 3. Create Role with Permissions
```typescript
// Create role
const role = await api.post('/roles', {
name: 'Account Manager',
description: 'Can manage accounts'
});
// Set permissions
await api.post('/role-rules', {
roleId: role.id,
rulesJson: [
{
action: ['read', 'update'],
subject: 'Account',
conditions: { ownerId: '$userId' } // Only own accounts
},
{
action: ['read'],
subject: 'Contact' // Can read all contacts
}
]
});
```
### 4. Query with Authorization
```typescript
// In a controller
async getRecords(user: User) {
const objectDef = await ObjectDefinition.query(this.knex)
.findOne({ apiName: 'Post' });
const query = Post.query(this.knex);
applyReadScope(query, user, objectDef, this.knex);
return query; // Only returns records user can read
}
```
### 5. Check Instance Permission
```typescript
// With CASL
const post = await Post.query().findById(id);
if (ability.can('update', subject(post, 'Post'))) {
// User can update this post
}
```
## Migration Guide
1. **Run Migration**
```bash
npm run migrate:latest
```
2. **Update Existing Objects**
Set default access model for existing object types:
```sql
UPDATE object_definitions
SET access_model = 'owner',
owner_field = 'ownerId'
WHERE access_model IS NULL;
```
3. **Update Controllers**
Add query scoping to list endpoints:
```typescript
import { applyReadScope } from '@/auth/query-scope.util';
// Before
const records = await MyModel.query();
// After
const records = await applyReadScope(
MyModel.query(),
user,
objectDef,
knex
);
```
4. **Add Guards**
Protect routes with ability checks:
```typescript
@UseGuards(JwtAuthGuard, AbilitiesGuard)
@CheckAbility({ action: 'read', subject: 'Post' })
async findAll() {
// ...
}
```
## Security Considerations
1. **Always use SQL scoping for lists** - Don't rely on post-fetch filtering
2. **Validate share ownership** - Only grantor can update/revoke shares
3. **Check expiry and revocation** - Filter out invalid shares in queries
4. **Field-level filtering** - Strip unauthorized fields from request bodies
5. **Tenant isolation** - All queries should be scoped to current tenant (if multi-tenant)
## Testing
### Unit Tests
Test ability building:
```typescript
it('should allow owner to read their records', async () => {
const ability = await abilityFactory.buildForUser(user, knex);
const post = { id: '123', ownerId: user.id };
expect(ability.can('read', subject(post, 'Post'))).toBe(true);
});
```
### Integration Tests
Test query scoping:
```typescript
it('should only return owned records', async () => {
const query = Post.query(knex);
applyReadScope(query, user, objectDef, knex);
const records = await query;
expect(records.every(r => r.ownerId === user.id)).toBe(true);
});
```
## Future Enhancements
- [ ] Group/team sharing (share with multiple users)
- [ ] Public link sharing (token-based)
- [ ] Audit log for shares
- [ ] Share templates
- [ ] Cascading shares (share related records)
- [ ] Time-limited shares with auto-expiry
- [ ] Share approval workflow
- [ ] Delegation (share on behalf of another user)
## API Reference
See individual controller files for detailed API documentation:
- [ShareController](./backend/src/rbac/share.controller.ts)
- [RoleController](./backend/src/rbac/role.controller.ts)
- [ObjectAccessController](./backend/src/object/object-access.controller.ts)

View File

@@ -0,0 +1,231 @@
# Central Admin Authentication Guide
## Overview
The platform now supports **two types of authentication**:
1. **Tenant Login** - Authenticates users against a specific tenant's database
2. **Central Admin Login** - Authenticates administrators against the central platform database
## Central vs Tenant Authentication
### Tenant Authentication (Default)
- Users login to their specific tenant database
- Each tenant has isolated user tables
- Access is scoped to the tenant's data
- API Endpoint: `/api/auth/login`
- Requires `x-tenant-id` header or subdomain detection
### Central Admin Authentication
- Administrators login to the central platform database
- Can manage all tenants and platform-wide features
- Users stored in the central database `users` table
- API Endpoint: `/api/central/auth/login`
- No tenant ID required
## Creating a Central Admin User
### Quick Start
```bash
cd backend
npm run create-central-admin
```
Follow the interactive prompts to create your admin user.
### Environment Variable Method
```bash
EMAIL=admin@platform.com \
PASSWORD=SecureP@ssw0rd \
FIRST_NAME=Admin \
LAST_NAME=User \
ROLE=superadmin \
npm run create-central-admin
```
### Role Types
- **admin** - Standard administrator with platform management access
- **superadmin** - Super administrator with full platform access
## Logging In as Central Admin
### Frontend Login
1. Navigate to the login page (`/login`)
2. **Check the "Login as Central Admin" checkbox**
3. Enter your central admin email and password
4. Click "Login to Central"
The checkbox toggles between:
-**Checked** - Authenticates against central database
-**Unchecked** - Authenticates against tenant database (default)
### API Login (Direct)
**Central Admin Login:**
```bash
curl -X POST http://localhost:3000/api/central/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@platform.com",
"password": "SecureP@ssw0rd"
}'
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "cm5a1b2c3d4e5f6g7h8i9j0k",
"email": "admin@platform.com",
"firstName": "Admin",
"lastName": "User",
"role": "superadmin",
"isCentralAdmin": true
}
}
```
**Tenant Login (for comparison):**
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-H "x-tenant-id: tenant1" \
-d '{
"email": "user@tenant1.com",
"password": "password123"
}'
```
## JWT Token Differences
### Central Admin Token Payload
```json
{
"sub": "user-id",
"email": "admin@platform.com",
"isCentralAdmin": true,
"iat": 1234567890,
"exp": 1234654290
}
```
### Tenant User Token Payload
```json
{
"sub": "user-id",
"email": "user@tenant1.com",
"iat": 1234567890,
"exp": 1234654290
}
```
The `isCentralAdmin` flag in the JWT can be used to determine if the user is a central admin.
## Database Schema
### Central Database - `users` Table
```sql
CREATE TABLE users (
id VARCHAR(30) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
firstName VARCHAR(100),
lastName VARCHAR(100),
role VARCHAR(50) DEFAULT 'admin',
isActive BOOLEAN DEFAULT true,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### Tenant Database - `users` Table
Tenant databases have their own separate `users` table with similar structure but tenant-specific users.
## Security Considerations
1. **Separate Password Storage** - Central admin passwords are stored separately from tenant user passwords
2. **Role-Based Access** - Central admins have different permissions than tenant users
3. **JWT Identification** - The `isCentralAdmin` flag helps identify admin users
4. **Encryption** - All passwords are hashed using bcrypt with salt rounds
## Common Use Cases
### Platform Administration
- **Login as:** Central Admin
- **Can do:**
- Create/manage tenants
- View all tenant information
- Manage platform-wide settings
- Access tenant provisioning APIs
### Tenant Management
- **Login as:** Tenant User
- **Can do:**
- Access tenant-specific data
- Manage records within the tenant
- Use tenant applications
- Limited to tenant scope
## Troubleshooting
### "Tenant ID is required" Error
- You're trying to login to tenant endpoint without tenant ID
- Solution: Either provide `x-tenant-id` header or use central admin login
### "Invalid credentials" with Central Login
- Check that you're using the "Login as Central Admin" checkbox
- Verify the user exists in the central database
- Use the script to create a central admin if needed
### "User already exists"
- A central admin with that email already exists
- Use a different email or reset the existing user's password
## Architecture
```
┌─────────────────────────────────────────┐
│ Frontend Login Form │
│ ┌────────────────────────────────────┐ │
│ │ ☑ Login as Central Admin │ │
│ └────────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
┌───────┴────────┐
│ Checked? │
└───────┬────────┘
┌──────────┴──────────┐
│ │
▼ ▼
/api/central/auth/login /api/auth/login
│ │
▼ ▼
Central Database Tenant Database
(users table) (users table)
```
## API Endpoints Summary
| Endpoint | Purpose | Requires Tenant ID | Database |
|----------|---------|-------------------|----------|
| `POST /api/central/auth/login` | Central admin login | ❌ No | Central |
| `POST /api/central/auth/register` | Create central admin | ❌ No | Central |
| `POST /api/auth/login` | Tenant user login | ✅ Yes | Tenant |
| `POST /api/auth/register` | Create tenant user | ✅ Yes | Tenant |
## Next Steps
1. Create your first central admin user
2. Login with the central admin checkbox enabled
3. Access platform administration features
4. Manage tenants and platform settings
For tenant management and provisioning, see [TENANT_MIGRATION_GUIDE.md](../TENANT_MIGRATION_GUIDE.md).

130
docs/CENTRAL_LOGIN.md Normal file
View File

@@ -0,0 +1,130 @@
# Central Admin Login
## Overview
The platform supports seamless authentication for both **tenant users** and **central administrators** using the same login endpoint. The system automatically determines which database to authenticate against based on the subdomain.
## How It Works
### Subdomain-Based Routing
The authentication flow uses subdomain detection to determine the authentication context:
1. **Central Subdomains** (e.g., `central.yourdomain.com`, `admin.yourdomain.com`)
- Authenticates against the **central database**
- Used for platform administrators
- Configured via `CENTRAL_SUBDOMAINS` environment variable
2. **Tenant Subdomains** (e.g., `acme.yourdomain.com`, `client1.yourdomain.com`)
- Authenticates against the **tenant's database**
- Used for regular tenant users
- Each tenant has its own isolated database
### Configuration
Set the central subdomains in your `.env` file:
```bash
# Comma-separated list of subdomains that access the central database
CENTRAL_SUBDOMAINS="central,admin"
```
### Implementation Details
#### 1. Tenant Middleware (`tenant.middleware.ts`)
The middleware extracts the subdomain from the request and:
- Checks if it matches a central subdomain
- If yes: Skips tenant resolution and attaches subdomain to request
- If no: Resolves the tenant ID from the subdomain and attaches it to request
#### 2. Auth Service (`auth.service.ts`)
The auth service has branching logic in `validateUser()` and `register()`:
- Checks if the subdomain is in the central list
- Routes to `validateCentralUser()` or normal tenant user validation
- Central users are authenticated against the `central` database
- Tenant users are authenticated against their tenant's database
#### 3. Auth Controller (`auth.controller.ts`)
The controller:
- Extracts subdomain from the request
- Validates tenant ID requirement (not needed for central subdomains)
- Passes subdomain to auth service for proper routing
## Usage
### Creating a Central Admin User
```bash
cd backend
npm run create-central-admin
```
Follow the prompts to enter:
- Email
- Password
- First Name (optional)
- Last Name (optional)
### Logging In as Central Admin
1. Navigate to `central.yourdomain.com` (or whatever central subdomain you configured)
2. Enter your central admin email and password
3. You'll be authenticated against the central database
**No special UI elements needed** - the system automatically detects the subdomain!
### Logging In as Tenant User
1. Navigate to `yourtenantslug.yourdomain.com`
2. Enter your tenant user credentials
3. You'll be authenticated against that tenant's database
## Architecture Benefits
**Transparent to Frontend** - No need for special "login as admin" checkboxes or UI elements
**Secure** - Central and tenant authentication are completely separated
**Scalable** - Easy to add more central subdomains by updating environment variable
**Clean Code** - Single auth controller/service with clear branching logic
**Flexible** - Can be used for both development (localhost) and production
## Local Development
For local development, you can:
1. **Use subdomain on localhost:**
```
central.localhost:3000
acme.localhost:3000
```
2. **Use x-tenant-id header** (for tenant-specific requests):
```bash
curl -H "x-tenant-id: acme-corp" http://localhost:3000/api/auth/login
```
3. **For central admin, use central subdomain:**
```bash
curl http://central.localhost:3000/api/auth/login
```
## Database Schema
### Central Database (`User` model)
- Stores platform administrators
- Prisma schema: `schema-central.prisma`
- Fields: id, email, password, firstName, lastName, isActive, createdAt, updatedAt
### Tenant Database (`users` table)
- Stores tenant-specific users
- Knex migrations: `migrations/tenant/`
- Fields: id, email, password, firstName, lastName, isActive, created_at, updated_at
## Security Considerations
- Central admin credentials are never stored in tenant databases
- Tenant user credentials are never stored in the central database
- JWT tokens include user context (tenant ID or central admin flag)
- Subdomain validation prevents unauthorized access

View File

@@ -0,0 +1,324 @@
# Custom Migrations Implementation
## Overview
This implementation adds a database-stored migration system for dynamically created objects. Migrations are recorded in a `custom_migrations` table in each tenant database, allowing them to be replayed or used for environment replication in the future.
## Architecture
### Components
#### 1. CustomMigrationService
**Location:** `backend/src/migration/custom-migration.service.ts`
Handles all migration-related operations:
- **`generateCreateTableSQL(tableName, fields)`** - Generates SQL for creating object tables with standard fields
- **`createMigrationRecord()`** - Stores migration metadata in the database
- **`executeMigration()`** - Executes a pending migration and updates its status
- **`createAndExecuteMigration()`** - Creates and immediately executes a migration
- **`getMigrations()`** - Retrieves migration history with filtering
- **`ensureMigrationsTable()`** - Ensures the `custom_migrations` table exists
#### 2. MigrationModule
**Location:** `backend/src/migration/migration.module.ts`
Provides the CustomMigrationService to other modules.
#### 3. Updated ObjectService
**Location:** `backend/src/object/object.service.ts`
- Injects CustomMigrationService
- Calls `createAndExecuteMigration()` when a new object is created
- Generates table creation migrations with standard fields
### Database Schema
#### custom_migrations Table
```sql
CREATE TABLE custom_migrations (
id UUID PRIMARY KEY,
tenantId UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
type ENUM('create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom'),
sql TEXT NOT NULL,
status ENUM('pending', 'executed', 'failed') DEFAULT 'pending',
executedAt TIMESTAMP NULL,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_tenantId (tenantId),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
)
```
#### Generated Object Tables
When a new object is created (e.g., "Account"), a table is automatically created with:
```sql
CREATE TABLE accounts (
id VARCHAR(36) PRIMARY KEY,
ownerId VARCHAR(36),
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Custom fields added here
)
```
**Standard Fields:**
- `id` - UUID primary key
- `ownerId` - User who owns the record
- `name` - Primary name field
- `created_at` - Record creation timestamp
- `updated_at` - Record update timestamp
### Field Type Mapping
Custom fields are mapped to SQL column types:
| Field Type | SQL Type | Notes |
|---|---|---|
| TEXT, STRING | VARCHAR(255) | |
| LONG_TEXT | TEXT | Large text content |
| NUMBER, DECIMAL | DECIMAL(18, 2) | |
| INTEGER | INT | |
| BOOLEAN | BOOLEAN | Defaults to FALSE |
| DATE | DATE | |
| DATE_TIME | DATETIME | |
| EMAIL | VARCHAR(255) | |
| URL | VARCHAR(2048) | |
| PHONE | VARCHAR(20) | |
| CURRENCY | DECIMAL(18, 2) | |
| PERCENT | DECIMAL(5, 2) | |
| PICKLIST, MULTI_PICKLIST | VARCHAR(255) | |
| LOOKUP, BELONGS_TO | VARCHAR(36) | References foreign record ID |
## Usage Flow
### Creating a New Object
1. **User creates object definition:**
```
POST /api/objects
{
"apiName": "Account",
"label": "Account",
"description": "Customer account records"
}
```
2. **ObjectService.createObjectDefinition() executes:**
- Inserts object metadata into `object_definitions` table
- Generates create table SQL
- Creates migration record with status "pending"
- Executes migration immediately
- Updates migration status to "executed"
- Returns object definition
3. **Result:**
- Object is now ready to use
- Table exists in database
- Migration history is recorded for future replication
### Migration Execution Flow
```
createAndExecuteMigration()
├── createMigrationRecord()
│ └── Insert into custom_migrations (status: pending)
└── executeMigration()
├── Fetch migration record
├── Execute SQL
├── Update status: executed
└── Return migration record
```
## Error Handling
Migrations track execution status and errors:
- **Status: pending** - Not yet executed
- **Status: executed** - Successfully completed
- **Status: failed** - Error during execution
Failed migrations are logged and stored with error details for debugging and retry:
```typescript
{
id: "uuid",
status: "failed",
error: "Syntax error in SQL...",
executedAt: null,
updated_at: "2025-12-24T11:00:00Z"
}
```
## Future Functionality
### Sandbox Environment Replication
Stored migrations enable:
1. **Cloning production environments** - Replay all migrations in new database
2. **Data structure export/import** - Export migrations as SQL files
3. **Audit trail** - Complete history of schema changes
4. **Rollback capability** - Add down migrations for reverting changes
5. **Dependency tracking** - Identify object dependencies from migrations
### Planned Enhancements
1. **Add down migrations** - Support undoing schema changes
2. **Migration dependencies** - Track which migrations depend on others
3. **Batch execution** - Run pending migrations together
4. **Version control** - Track migration versions and changes
5. **Manual migration creation** - API to create custom migrations
6. **Migration status dashboard** - UI to view migration history
## Integration Points
### ObjectService
- Uses `getTenantKnexById()` for tenant database connections
- Calls CustomMigrationService after creating object definitions
- Handles migration execution errors gracefully (logs but doesn't fail)
### TenantDatabaseService
- Provides database connections via `getTenantKnexById()`
- Connections are cached with prefix `id:${tenantId}`
### Module Dependencies
```
ObjectModule
├── imports: [TenantModule, MigrationModule]
└── providers: [ObjectService, CustomMigrationService, ...]
MigrationModule
├── imports: [TenantModule]
└── providers: [CustomMigrationService]
```
## API Endpoints (Future)
While not yet exposed via API, these operations could be added:
```typescript
// Get migration history
GET /api/migrations?tenantId=xxx&status=executed
// Get migration details
GET /api/migrations/:id
// Retry failed migration
POST /api/migrations/:id/retry
// Export migrations as SQL
GET /api/migrations/export?tenantId=xxx
// Create custom migration
POST /api/migrations
{
name: "add_field_to_accounts",
description: "Add phone_number field",
sql: "ALTER TABLE accounts ADD phone_number VARCHAR(20)"
}
```
## Testing
### Manual Testing Steps
1. **Create a new object:**
```bash
curl -X POST http://localhost:3000/api/objects \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"apiName": "TestObject",
"label": "Test Object",
"description": "Test object creation"
}'
```
2. **Verify table was created:**
```bash
# In tenant database
SHOW TABLES LIKE 'test_objects';
DESCRIBE test_objects;
```
3. **Check migration record:**
```bash
# In tenant database
SELECT * FROM custom_migrations WHERE name LIKE '%test_objects%';
```
4. **Create a record in the new object:**
```bash
curl -X POST http://localhost:3000/api/test-objects \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "My Test Record"
}'
```
## Troubleshooting
### Migration Fails with SQL Error
1. Check `custom_migrations` table for error details:
```sql
SELECT id, name, error, status FROM custom_migrations
WHERE status = 'failed';
```
2. Review the generated SQL in the `sql` column
3. Common issues:
- Duplicate table names
- Invalid field names (reserved SQL keywords)
- Unsupported field types
### Table Not Created
1. Verify `custom_migrations` table exists:
```sql
SHOW TABLES LIKE 'custom_migrations';
```
2. Check object service logs for migration execution errors
3. Manually retry migration:
```typescript
const migration = await tenantKnex('custom_migrations')
.where({ status: 'failed' })
.first();
await customMigrationService.executeMigration(tenantKnex, migration.id);
```
## Performance Considerations
- **Table creation** is synchronous and happens immediately
- **Migrations are cached** in custom_migrations table per tenant
- **No file I/O** - all operations use database
- **Index creation** optimized with proper indexes on common columns (tenantId, status, created_at)
## Security
- **Per-tenant isolation** - Each tenant's migrations stored separately
- **No SQL injection** - Using Knex query builder for all operations
- **Access control** - Migrations only created/executed by backend service
- **Audit trail** - Complete history of all schema changes
## Related Files
- [backend/src/object/object.service.ts](backend/src/object/object.service.ts)
- [backend/src/migration/custom-migration.service.ts](backend/src/migration/custom-migration.service.ts)
- [backend/src/migration/migration.module.ts](backend/src/migration/migration.module.ts)

View File

@@ -0,0 +1,406 @@
# Field Types System Architecture
## System Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Vue 3 + Nuxt) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ View Components │ │
│ ├───────────────────────────────────────────────────────────┤ │
│ │ ListView.vue │ DetailView.vue │ EditView.vue │ │
│ │ - Data Table │ - Read Display │ - Form │ │
│ │ - Search │ - Sections │ - Validation │ │
│ │ - Sort/Filter │ - Actions │ - Sections │ │
│ │ - Bulk Actions │ │ │ │
│ └────────────────────────┬──────────────────────────────────┘ │
│ │ uses │
│ ┌────────────────────────▼──────────────────────────────────┐ │
│ │ FieldRenderer.vue │ │
│ │ Universal component for rendering any field type │ │
│ │ - Handles LIST, DETAIL, EDIT modes │ │
│ │ - Type-aware rendering │ │
│ │ - Validation support │ │
│ └────────────────────────┬──────────────────────────────────┘ │
│ │ uses │
│ ┌────────────────────────▼──────────────────────────────────┐ │
│ │ shadcn-vue Components │ │
│ │ Input, Textarea, Select, Checkbox, Switch, Calendar, │ │
│ │ Table, Badge, Dialog, Popover, etc. │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Composables │ │
│ ├───────────────────────────────────────────────────────────┤ │
│ │ useFields() │ useViewState() │ │
│ │ - Map backend data │ - CRUD operations │ │
│ │ - Build configs │ - State management │ │
│ │ - Generate sections │ - Navigation │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Type Definitions │ │
│ │ field-types.ts - TypeScript interfaces for field system │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ HTTP/REST API
┌─────────────────────────────────────────────────────────────────┐
│ Backend (NestJS) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Controllers │ │
│ ├───────────────────────────────────────────────────────────┤ │
│ │ SetupObjectController │ RuntimeObjectController │ │
│ │ - GET /objects │ - GET /objects/:name │ │
│ │ - GET /objects/:name │ - GET /objects/:name/:id │ │
│ │ - GET /ui-config ✨ │ - POST /objects/:name │ │
│ │ - POST /objects │ - PUT /objects/:name/:id │ │
│ └────────────────────────┬────────────────┬─────────────────┘ │
│ │ │ │
│ ┌────────────────────────▼────────────────▼─────────────────┐ │
│ │ Services │ │
│ ├───────────────────────────────────────────────────────────┤ │
│ │ ObjectService │ FieldMapperService ✨ │ │
│ │ - CRUD operations │ - Map field definitions │ │
│ │ - Query building │ - Generate UI configs │ │
│ │ - Validation │ - Default metadata │ │
│ └────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼──────────────────────────────────┐ │
│ │ Models │ │
│ │ ObjectDefinition │ FieldDefinition ✨ │ │
│ │ - Object metadata │ - Field metadata │ │
│ │ │ - UIMetadata interface │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ Prisma/Knex
┌─────────────────────────────────────────────────────────────────┐
│ Database (PostgreSQL) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ object_definitions │ │
│ │ - id, tenant_id, api_name, label, plural_label │ │
│ │ - description, is_system, table_name │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ │ 1:many │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ field_definitions │ │
│ │ - id, object_definition_id, api_name, label, type │ │
│ │ - is_required, is_unique, is_system │ │
│ │ - ui_metadata (JSONB) ✨ NEW │ │
│ │ { │ │
│ │ placeholder, helpText, showOnList, showOnDetail, │ │
│ │ showOnEdit, sortable, options, rows, min, max, │ │
│ │ validationRules, format, prefix, suffix, etc. │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
✨ = New/Enhanced component
```
## Data Flow
### 1. Loading Object Definition
```
┌──────────┐ GET /api/setup/objects/Contact/ui-config ┌──────────┐
│ │ ──────────────────────────────────────────────────> │ │
│ Frontend │ │ Backend │
│ │ <────────────────────────────────────────────────── │ │
└──────────┘ { objectDef with mapped fields } └──────────┘
│ useFields().buildListViewConfig(objectDef)
┌──────────────────────────────────────┐
│ ListViewConfig │
│ - objectApiName: "Contact" │
│ - mode: "list" │
│ - fields: [ │
│ { │
│ apiName: "firstName", │
│ type: "text", │
│ showOnList: true, │
│ ... │
│ } │
│ ] │
└──────────────────────────────────────┘
│ Pass to ListView component
┌──────────────────────────────────────┐
│ ListView renders data table │
└──────────────────────────────────────┘
```
### 2. Fetching Records
```
┌──────────┐ GET /api/runtime/objects/Contact ┌──────────┐
│ │ ──────────────────────────────────────────────────> │ │
│ Frontend │ │ Backend │
│ │ <────────────────────────────────────────────────── │ │
└──────────┘ [{ id, firstName, lastName, ... }] └──────────┘
┌──────────────────────────────────────┐
│ ListView displays records │
│ Each field rendered by │
│ FieldRenderer with mode="list" │
└──────────────────────────────────────┘
```
### 3. Field Rendering
```
┌─────────────────────────────────────────────────────────────┐
│ FieldRenderer │
│ Props: { field, modelValue, mode } │
└────────────────────────┬────────────────────────────────────┘
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
mode="list" mode="detail" mode="edit"
│ │ │
▼ ▼ ▼
Simple text Formatted Input component
or badge display with based on type:
display labels - Input
- Textarea
- Select
- DatePicker
- Checkbox
- etc.
```
### 4. Saving Record
```
┌──────────┐ ┌──────────┐
│ EditView │ ──> User fills form ──> Validation │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Valid? │ │
│ │ ✓ Yes │ │
│ │ @save event │ │ │
│ │ ──────────────────────────┘ │ │
│ │ │ │
│ │ POST/PUT /api/runtime/objects/Contact/:id │ Backend │
│ Frontend │ ──────────────────────────────────────────────────> │ │
│ │ │ │
│ │ <────────────────────────────────────────────────── │ │
│ │ { saved record } │ │
│ │ │ │
│ │ ──> Navigate to DetailView │ │
└──────────┘ └──────────┘
```
## Component Hierarchy
```
Page/App
└── ObjectViewContainer
├── ListView
│ ├── Search/Filters
│ ├── Table
│ │ ├── TableHeader
│ │ │ └── Sortable columns
│ │ └── TableBody
│ │ └── TableRow (for each record)
│ │ └── TableCell (for each field)
│ │ └── FieldRenderer (mode="list")
│ └── Actions (Create, Export, etc.)
├── DetailView
│ ├── Header with actions
│ └── Sections
│ └── Card (for each section)
│ └── FieldRenderer (mode="detail") for each field
└── EditView
├── Header with Save/Cancel
└── Form
└── Sections
└── Card (for each section)
└── FieldRenderer (mode="edit") for each field
└── Input component based on field type
```
## Field Type Mapping
```
Database Type → FieldType Enum → Component (Edit Mode)
─────────────────────────────────────────────────────────
string → TEXT → Input[type="text"]
text → TEXTAREA → Textarea
email → EMAIL → Input[type="email"]
url → URL → Input[type="url"]
integer → NUMBER → Input[type="number"]
decimal → NUMBER → Input[type="number"]
currency → CURRENCY → Input[type="number"] + prefix
boolean → BOOLEAN → Checkbox
date → DATE → DatePicker
datetime → DATETIME → DatePicker (with time)
picklist → SELECT → Select
multipicklist → MULTI_SELECT → Select[multiple]
lookup → BELONGS_TO → Combobox (relation picker)
file → FILE → FileUpload
image → IMAGE → ImageUpload
richtext → MARKDOWN → Textarea (+ preview)
json → JSON → Textarea (JSON editor)
```
## View Mode Rendering
```
Field Type: TEXT
─────────────────────────────────────────────────────
LIST mode │ Simple text, truncated
│ <span>{{ value }}</span>
─────────────────────────────────────────────────────
DETAIL mode │ Text with label
│ <div>
│ <Label>Name</Label>
│ <span>{{ value }}</span>
│ </div>
─────────────────────────────────────────────────────
EDIT mode │ Input field
│ <Input v-model="value" />
─────────────────────────────────────────────────────
Field Type: BOOLEAN
─────────────────────────────────────────────────────
LIST mode │ Badge (Yes/No)
│ <Badge>Yes</Badge>
─────────────────────────────────────────────────────
DETAIL mode │ Checkbox (disabled) + text
│ <Checkbox :checked="value" disabled />
│ <span>Yes</span>
─────────────────────────────────────────────────────
EDIT mode │ Checkbox (editable)
│ <Checkbox v-model="value" />
─────────────────────────────────────────────────────
Field Type: SELECT
─────────────────────────────────────────────────────
LIST mode │ Selected label
│ <span>Active</span>
─────────────────────────────────────────────────────
DETAIL mode │ Selected label with styling
│ <Badge>Active</Badge>
─────────────────────────────────────────────────────
EDIT mode │ Dropdown select
│ <Select v-model="value">
│ <SelectItem value="active">Active</SelectItem>
│ </Select>
─────────────────────────────────────────────────────
```
## API Endpoints
```
Setup/Configuration (Metadata)
────────────────────────────────────────────────────
GET /api/setup/objects
Returns: List of all object definitions
GET /api/setup/objects/:objectName
Returns: Object definition with fields
GET /api/setup/objects/:objectName/ui-config ✨
Returns: Object definition with UI-ready field configs
(fields mapped to frontend format with UIMetadata)
POST /api/setup/objects
Body: { apiName, label, description, ... }
Returns: Created object definition
POST /api/setup/objects/:objectName/fields
Body: { apiName, label, type, uiMetadata, ... }
Returns: Created field definition
Runtime (Data CRUD)
────────────────────────────────────────────────────
GET /api/runtime/objects/:objectName
Query: { search, filters, page, pageSize }
Returns: Array of records
GET /api/runtime/objects/:objectName/:recordId
Returns: Single record
POST /api/runtime/objects/:objectName
Body: { field1: value1, field2: value2, ... }
Returns: Created record
PUT /api/runtime/objects/:objectName/:recordId
Body: { field1: value1, field2: value2, ... }
Returns: Updated record
DELETE /api/runtime/objects/:objectName/:recordId
Returns: Success status
```
## Key Features
### Frontend
- ✅ Universal field renderer for 15+ field types
- ✅ Three view modes (list, detail, edit)
- ✅ Client-side validation with custom rules
- ✅ Responsive design (mobile-friendly)
- ✅ Accessible components (WCAG compliant)
- ✅ Type-safe with TypeScript
- ✅ Composables for easy integration
- ✅ Demo page for testing
### Backend
- ✅ UI metadata stored in JSONB column
- ✅ Field mapper service for transformation
- ✅ Default metadata generation
- ✅ Validation rule support
- ✅ Flexible field type system
- ✅ Multi-tenant support
- ✅ RESTful API
### Database
- ✅ Flexible schema with JSONB metadata
- ✅ Support for custom objects
- ✅ Versioning and migration support
- ✅ Indexed for performance
## Extension Points
```
1. Custom Field Types
└─> Add to FieldType enum
└─> Add rendering logic to FieldRenderer.vue
└─> Add mapping in FieldMapperService
2. Custom Validation Rules
└─> Add to ValidationRule type
└─> Add validation logic in EditView.vue
3. Custom Actions
└─> Add to ViewAction interface
└─> Handle in view components
4. Custom Sections
└─> Configure in DetailViewConfig/EditViewConfig
└─> Auto-generation in useFields()
5. Custom Formatting
└─> Add to UIMetadata
└─> Implement in FieldRenderer.vue
```
This architecture provides a scalable, maintainable, and extensible system for building dynamic forms and views! 🎉

View File

@@ -0,0 +1,282 @@
# Field Types System - Implementation Checklist
Use this checklist to ensure proper implementation of the field type system in your production environment.
## ✅ Backend Setup
### Database
- [ ] Run migration: `npm run migrate:tenant` to add `ui_metadata` column
- [ ] Verify migration succeeded: Check `field_definitions` table has `ui_metadata` column
- [ ] (Optional) Run seed: `knex seed:run --specific=example_account_fields_with_ui_metadata.js`
- [ ] Test database access with sample queries
### Services
- [ ] Verify `FieldMapperService` is registered in `ObjectModule`
- [ ] Test field mapping: Call `mapFieldDefinitionToConfig()` with sample field
- [ ] Verify default UI metadata generation works
- [ ] Test `mapObjectDefinitionToDTO()` with full object
### Controllers
- [ ] Verify `/api/setup/objects/:objectName/ui-config` endpoint works
- [ ] Test endpoint returns properly formatted field configs
- [ ] Verify authentication/authorization works on endpoints
- [ ] Test with different tenant IDs
### Models
- [ ] Confirm `FieldDefinition` model has `uiMetadata` property
- [ ] Verify `UIMetadata` interface is properly typed
- [ ] Test CRUD operations with UI metadata
## ✅ Frontend Setup
### Dependencies
- [ ] Verify all shadcn-vue components are installed
- [ ] Check: `table`, `input`, `select`, `checkbox`, `switch`, `textarea`, `calendar`, `badge`, `dialog`
- [ ] Confirm `components.json` is properly configured
- [ ] Test component imports work
### Type Definitions
- [ ] Verify `/frontend/types/field-types.ts` exists
- [ ] Check all `FieldType` enum values are defined
- [ ] Verify interface exports work across components
- [ ] Test TypeScript compilation with no errors
### Components
- [ ] Test `FieldRenderer.vue` with all field types
- [ ] Verify `ListView.vue` renders data table correctly
- [ ] Test `DetailView.vue` with sections and collapsibles
- [ ] Verify `EditView.vue` form validation works
- [ ] Test `DatePicker.vue` component
### Composables
- [ ] Test `useFields()` mapping functions
- [ ] Verify `useViewState()` CRUD operations
- [ ] Test state management and navigation
- [ ] Verify error handling works
### Pages
- [ ] Test demo page at `/demo/field-views`
- [ ] Verify dynamic route at `/app/objects/:objectName`
- [ ] Test all three views (list, detail, edit)
- [ ] Verify navigation between views works
## ✅ Integration Testing
### End-to-End Flows
- [ ] Create new object definition via API
- [ ] Add fields with UI metadata
- [ ] Fetch object UI config from frontend
- [ ] Render ListView with real data
- [ ] Click row to view DetailView
- [ ] Click edit to view EditView
- [ ] Submit form and verify save works
- [ ] Delete record and verify it's removed
### Field Type Testing
Test each field type in all three modes:
#### Text Fields
- [ ] TEXT - List, Detail, Edit modes
- [ ] TEXTAREA - List, Detail, Edit modes
- [ ] PASSWORD - Edit mode (masked)
- [ ] EMAIL - All modes with validation
- [ ] URL - All modes with validation
#### Numeric Fields
- [ ] NUMBER - All modes
- [ ] CURRENCY - All modes with prefix/suffix
#### Selection Fields
- [ ] SELECT - All modes with options
- [ ] MULTI_SELECT - All modes with options
- [ ] BOOLEAN - All modes (badge, checkbox)
#### Date/Time Fields
- [ ] DATE - All modes with date picker
- [ ] DATETIME - All modes with date/time picker
### Validation Testing
- [ ] Required field validation
- [ ] Email format validation
- [ ] URL format validation
- [ ] Min/max length validation
- [ ] Min/max value validation
- [ ] Pattern matching validation
- [ ] Custom validation rules
### UI/UX Testing
- [ ] Responsive design on mobile devices
- [ ] Keyboard navigation works
- [ ] Focus management is correct
- [ ] Loading states display properly
- [ ] Error messages are clear
- [ ] Success feedback is visible
- [ ] Tooltips and help text display
## ✅ Performance Testing
### Frontend
- [ ] ListView handles 100+ records smoothly
- [ ] Sorting is fast
- [ ] Search is responsive
- [ ] Form submission is snappy
- [ ] No memory leaks on navigation
- [ ] Component re-renders are optimized
### Backend
- [ ] Field mapping is performant
- [ ] Database queries are optimized
- [ ] API response times are acceptable
- [ ] Bulk operations handle multiple records
- [ ] Concurrent requests handled properly
## ✅ Security Checklist
### Authentication
- [ ] All API endpoints require authentication
- [ ] JWT tokens are validated
- [ ] Tenant isolation is enforced
- [ ] User permissions are checked
### Authorization
- [ ] Read permissions enforced
- [ ] Write permissions enforced
- [ ] Delete permissions enforced
- [ ] Field-level security (if needed)
### Input Validation
- [ ] Server-side validation on all inputs
- [ ] SQL injection prevention
- [ ] XSS prevention in field values
- [ ] CSRF protection enabled
### Data Protection
- [ ] Sensitive fields masked appropriately
- [ ] Audit logging for changes
- [ ] Data encryption at rest (if needed)
- [ ] Proper error messages (no leaking)
## ✅ Documentation
### Code Documentation
- [ ] JSDoc comments on key functions
- [ ] TypeScript interfaces documented
- [ ] Complex logic explained with comments
- [ ] README files in each major directory
### User Documentation
- [ ] Quick start guide available
- [ ] Field types reference documented
- [ ] API endpoints documented
- [ ] Common use cases documented
- [ ] Troubleshooting guide available
## ✅ Production Readiness
### Deployment
- [ ] Environment variables configured
- [ ] Database connection verified
- [ ] API endpoints accessible
- [ ] Frontend build succeeds
- [ ] Assets are served correctly
### Monitoring
- [ ] Error tracking configured (Sentry, etc.)
- [ ] Performance monitoring enabled
- [ ] API rate limiting configured
- [ ] Log aggregation set up
- [ ] Alerts configured for critical issues
### Backup & Recovery
- [ ] Database backup strategy defined
- [ ] Recovery procedures documented
- [ ] Migration rollback tested
- [ ] Data export functionality works
### Scaling
- [ ] Database indexes optimized
- [ ] API caching strategy defined
- [ ] CDN configured for static assets
- [ ] Load balancing tested (if applicable)
## ✅ Quality Assurance
### Testing Coverage
- [ ] Unit tests for services
- [ ] Integration tests for API endpoints
- [ ] Component tests for views
- [ ] E2E tests for critical flows
- [ ] Test coverage > 70%
### Code Quality
- [ ] Linting passes with no errors
- [ ] TypeScript strict mode enabled
- [ ] Code reviews completed
- [ ] No console errors in production
- [ ] Accessibility audit passed
### Browser Compatibility
- [ ] Chrome/Chromium tested
- [ ] Firefox tested
- [ ] Safari tested
- [ ] Edge tested
- [ ] Mobile browsers tested
## ✅ Maintenance Plan
### Regular Tasks
- [ ] Dependency updates scheduled
- [ ] Security patches applied promptly
- [ ] Performance monitoring reviewed
- [ ] User feedback collected
- [ ] Bug fix process defined
### Future Enhancements
- [ ] Custom field types roadmap
- [ ] Advanced validation rules planned
- [ ] Relationship field implementation
- [ ] File upload functionality
- [ ] Rich text editor integration
## 🎯 Success Criteria
Your field type system is production-ready when:
- ✅ All backend endpoints return correct data
- ✅ All frontend views render without errors
- ✅ All field types display correctly in all modes
- ✅ Form validation works as expected
- ✅ CRUD operations complete successfully
- ✅ Performance meets requirements
- ✅ Security measures are in place
- ✅ Documentation is complete
- ✅ Team is trained on usage
- ✅ Monitoring is active
## 📝 Sign-Off
Once all items are checked, have the following team members sign off:
- [ ] Backend Developer: _________________ Date: _______
- [ ] Frontend Developer: ________________ Date: _______
- [ ] QA Engineer: ______________________ Date: _______
- [ ] DevOps Engineer: ___________________ Date: _______
- [ ] Product Manager: ___________________ Date: _______
## 🚀 Launch Readiness
- [ ] All checklist items completed
- [ ] Stakeholders notified
- [ ] Launch date confirmed
- [ ] Rollback plan prepared
- [ ] Support team briefed
**Ready for production!** 🎉
---
**Notes:**
- Keep this checklist updated as new features are added
- Review quarterly for improvements
- Share learnings with the team
- Celebrate successes! 🎊

479
docs/FIELD_TYPES_GUIDE.md Normal file
View File

@@ -0,0 +1,479 @@
# Field Types & Views System
A comprehensive field type system inspired by Laravel Nova, built with Vue 3 and shadcn-vue components. This system provides a flexible way to define and render fields in list, detail, and edit views.
## Overview
The system consists of:
1. **Field Type Definitions** - TypeScript types and enums defining all available field types
2. **Field Renderer** - A universal component that renders fields based on type and view mode
3. **View Components** - ListView (data table), DetailView, and EditView components
4. **Composables** - Utilities for working with fields and managing CRUD operations
5. **Backend Support** - Extended field definitions with UI metadata
## Field Types
### Text Fields
- `TEXT` - Single-line text input
- `TEXTAREA` - Multi-line text input
- `PASSWORD` - Password input (masked)
- `EMAIL` - Email input with validation
- `URL` - URL input
### Numeric Fields
- `NUMBER` - Numeric input
- `CURRENCY` - Currency input with formatting
### Selection Fields
- `SELECT` - Dropdown select
- `MULTI_SELECT` - Multi-select dropdown
- `BOOLEAN` - Checkbox/switch
### Date/Time Fields
- `DATE` - Date picker
- `DATETIME` - Date and time picker
- `TIME` - Time picker
### Relationship Fields
- `BELONGS_TO` - Many-to-one relationship
- `HAS_MANY` - One-to-many relationship
- `MANY_TO_MANY` - Many-to-many relationship
### Rich Content
- `MARKDOWN` - Markdown editor
- `CODE` - Code editor
### File Fields
- `FILE` - File upload
- `IMAGE` - Image upload
### Other
- `COLOR` - Color picker
- `JSON` - JSON editor
## Usage
### Basic Example
```vue
<script setup lang="ts">
import { ListView, DetailView, EditView } from '@/components/views'
import { FieldType, ViewMode } from '@/types/field-types'
// Define your fields
const fields = [
{
id: '1',
apiName: 'name',
label: 'Name',
type: FieldType.TEXT,
isRequired: true,
placeholder: 'Enter name',
showOnList: true,
showOnDetail: true,
showOnEdit: true,
},
{
id: '2',
apiName: 'email',
label: 'Email',
type: FieldType.EMAIL,
isRequired: true,
validationRules: [
{ type: 'email', message: 'Invalid email format' }
],
},
{
id: '3',
apiName: 'status',
label: 'Status',
type: FieldType.SELECT,
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
],
},
]
// Create view config
const listConfig = {
objectApiName: 'Contact',
mode: ViewMode.LIST,
fields,
searchable: true,
exportable: true,
}
const data = ref([])
</script>
<template>
<ListView
:config="listConfig"
:data="data"
selectable
@row-click="handleRowClick"
@create="handleCreate"
/>
</template>
```
### Using with Backend Data
```vue
<script setup lang="ts">
import { useFields, useViewState } from '@/composables/useFieldViews'
import { ListView } from '@/components/views'
const { buildListViewConfig } = useFields()
const {
records,
loading,
fetchRecords,
showDetail,
showEdit,
deleteRecords
} = useViewState('/api/contacts')
// Fetch object definition from backend
const objectDef = await $fetch('/api/objects/contact')
// Build view config from backend data
const listConfig = buildListViewConfig(objectDef, {
searchable: true,
exportable: true,
})
// Fetch records
await fetchRecords()
</script>
<template>
<ListView
:config="listConfig"
:data="records"
:loading="loading"
@row-click="showDetail"
@create="showEdit"
@delete="deleteRecords"
/>
</template>
```
### Sections and Grouping
```typescript
const detailConfig = {
objectApiName: 'Contact',
mode: ViewMode.DETAIL,
fields,
sections: [
{
title: 'Basic Information',
description: 'Primary contact details',
fields: ['firstName', 'lastName', 'email'],
},
{
title: 'Company Information',
fields: ['company', 'jobTitle', 'department'],
},
{
title: 'Additional Details',
fields: ['notes', 'tags'],
collapsible: true,
defaultCollapsed: true,
},
],
}
```
## Field Configuration
### FieldConfig Interface
```typescript
interface FieldConfig {
// Basic properties
id: string
apiName: string
label: string
type: FieldType
// Display
placeholder?: string
helpText?: string
defaultValue?: any
// Validation
isRequired?: boolean
isReadOnly?: boolean
validationRules?: FieldValidationRule[]
// View visibility
showOnList?: boolean
showOnDetail?: boolean
showOnEdit?: boolean
sortable?: boolean
// Type-specific options
options?: FieldOption[] // For select fields
rows?: number // For textarea
min?: number // For number/date
max?: number // For number/date
step?: number // For number
accept?: string // For file uploads
relationObject?: string // For relationships
// Formatting
format?: string
prefix?: string
suffix?: string
}
```
## Validation Rules
```typescript
const field = {
// ... other config
validationRules: [
{ type: 'required', message: 'This field is required' },
{ type: 'min', value: 5, message: 'Minimum 5 characters' },
{ type: 'max', value: 100, message: 'Maximum 100 characters' },
{ type: 'email', message: 'Invalid email format' },
{ type: 'url', message: 'Invalid URL format' },
{ type: 'pattern', value: '^[A-Z]', message: 'Must start with uppercase' },
],
}
```
## View Components
### ListView
Features:
- Data table with sortable columns
- Row selection with bulk actions
- Search functionality
- Custom actions
- Export capability
- Pagination support
Events:
- `row-click` - When a row is clicked
- `row-select` - When rows are selected
- `create` - When create button is clicked
- `edit` - When edit button is clicked
- `delete` - When delete is triggered
- `action` - When custom action is triggered
- `sort` - When column sort changes
- `search` - When search is performed
### DetailView
Features:
- Organized sections
- Collapsible sections
- Custom actions
- Read-only display optimized for each field type
Events:
- `edit` - When edit button is clicked
- `delete` - When delete button is clicked
- `back` - When back button is clicked
- `action` - When custom action is triggered
### EditView
Features:
- Form with validation
- Organized sections with collapsible support
- Required field indicators
- Help text and placeholders
- Error messages
- Save/Cancel actions
Events:
- `save` - When form is submitted (passes validated data)
- `cancel` - When cancel is clicked
- `back` - When back is clicked
## Backend Integration
### Field Definition Model
```typescript
export interface UIMetadata {
placeholder?: string
helpText?: string
showOnList?: boolean
showOnDetail?: boolean
showOnEdit?: boolean
sortable?: boolean
options?: FieldOption[]
rows?: number
min?: number
max?: number
step?: number
format?: string
prefix?: string
suffix?: string
validationRules?: ValidationRule[]
}
export class FieldDefinition extends BaseModel {
// ... existing fields
uiMetadata?: UIMetadata
}
```
### Migration
Run the migration to add UI metadata support:
```bash
cd backend
npm run migrate:tenant
```
### API Response Example
```json
{
"id": "field-1",
"objectDefinitionId": "obj-1",
"apiName": "firstName",
"label": "First Name",
"type": "text",
"isRequired": true,
"uiMetadata": {
"placeholder": "Enter first name",
"helpText": "Customer's legal first name",
"showOnList": true,
"showOnDetail": true,
"showOnEdit": true,
"sortable": true,
"validationRules": [
{
"type": "min",
"value": 2,
"message": "Name must be at least 2 characters"
}
]
}
}
```
## Composables
### useFields()
Utilities for working with field configurations:
- `mapFieldDefinitionToConfig(fieldDef)` - Convert backend field definition to FieldConfig
- `buildListViewConfig(objectDef, customConfig)` - Build ListView configuration
- `buildDetailViewConfig(objectDef, customConfig)` - Build DetailView configuration
- `buildEditViewConfig(objectDef, customConfig)` - Build EditView configuration
- `generateSections(fields)` - Auto-generate sections based on field types
### useViewState(apiEndpoint)
CRUD operations and state management:
- **State**: `records`, `currentRecord`, `currentView`, `loading`, `saving`, `error`
- **Methods**: `fetchRecords()`, `fetchRecord(id)`, `createRecord(data)`, `updateRecord(id, data)`, `deleteRecord(id)`, `deleteRecords(ids)`
- **Navigation**: `showList()`, `showDetail(record)`, `showEdit(record)`, `handleSave(data)`
## Demo
Visit `/demo/field-views` to see an interactive demo of all field types and views.
## Best Practices
1. **Field Organization** - Group related fields into sections for better UX
2. **Validation** - Always provide clear validation messages
3. **Help Text** - Use help text to guide users
4. **Required Fields** - Mark required fields appropriately
5. **Default Values** - Provide sensible defaults when possible
6. **Read-Only Fields** - Use for system fields or computed values
7. **Conditional Logic** - Use `dependsOn` for conditional field visibility
8. **Mobile Responsive** - All components are mobile-responsive by default
## Extending
### Adding Custom Field Types
1. Add new type to `FieldType` enum in [types/field-types.ts](../types/field-types.ts)
2. Add rendering logic to [FieldRenderer.vue](../components/fields/FieldRenderer.vue)
3. Update validation logic in [EditView.vue](../components/views/EditView.vue)
### Custom Actions
```typescript
const config = {
// ... other config
actions: [
{
id: 'export-pdf',
label: 'Export PDF',
icon: 'FileDown',
variant: 'outline',
confirmation: 'Export this record to PDF?',
handler: async () => {
// Custom logic
}
}
]
}
```
## Components Structure
```
frontend/
├── components/
│ ├── fields/
│ │ └── FieldRenderer.vue # Universal field renderer
│ ├── views/
│ │ ├── ListView.vue # Data table view
│ │ ├── DetailView.vue # Read-only detail view
│ │ └── EditView.vue # Form/edit view
│ └── ui/ # shadcn-vue components
│ ├── table/
│ ├── input/
│ ├── select/
│ ├── checkbox/
│ ├── switch/
│ ├── textarea/
│ ├── calendar/
│ ├── date-picker/
│ └── ...
├── types/
│ └── field-types.ts # Type definitions
├── composables/
│ └── useFieldViews.ts # Utilities
└── pages/
└── demo/
└── field-views.vue # Interactive demo
```
## Performance Considerations
- Fields are rendered on-demand based on view mode
- Large datasets should use pagination (built-in support)
- Validation is performed client-side before API calls
- Use `v-memo` for large lists to optimize re-renders
## Accessibility
All components follow accessibility best practices:
- Proper ARIA labels
- Keyboard navigation support
- Focus management
- Screen reader friendly
- High contrast support
## License
Part of the Neo platform.

View File

@@ -0,0 +1,267 @@
# Field Types & Views Implementation Summary
## What Was Built
A complete Laravel Nova-inspired field type system with list, detail, and edit views using shadcn-vue components.
## 📁 Files Created
### Frontend
#### Type Definitions
- **`/frontend/types/field-types.ts`** - Complete TypeScript definitions for field types, view modes, and configurations
#### Components
- **`/frontend/components/fields/FieldRenderer.vue`** - Universal field renderer that handles all field types in all view modes
- **`/frontend/components/views/ListView.vue`** - Data table with search, sort, filter, bulk actions
- **`/frontend/components/views/DetailView.vue`** - Read-only detail view with sections
- **`/frontend/components/views/EditView.vue`** - Form with validation and sections
- **`/frontend/components/ui/date-picker/DatePicker.vue`** - Custom date picker component
#### Composables
- **`/frontend/composables/useFieldViews.ts`** - Utilities for field mapping and CRUD operations
#### Pages
- **`/frontend/pages/demo/field-views.vue`** - Interactive demo page
- **`/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue`** - Dynamic object view page
### Backend
#### Models
- **Updated `/backend/src/models/field-definition.model.ts`** - Added UIMetadata interface and uiMetadata property
#### Services
- **`/backend/src/object/field-mapper.service.ts`** - Service for mapping backend field definitions to frontend configs
#### Controllers
- **Updated `/backend/src/object/setup-object.controller.ts`** - Added `/ui-config` endpoint
#### Migrations
- **`/backend/migrations/tenant/20250126000005_add_ui_metadata_to_fields.js`** - Database migration for UI metadata
### Documentation
- **`/FIELD_TYPES_GUIDE.md`** - Comprehensive documentation
- **`/FIELD_TYPES_IMPLEMENTATION_SUMMARY.md`** - This file
## 🎨 Field Types Supported
### Text Fields
- Text, Textarea, Password, Email, URL
### Numeric Fields
- Number, Currency
### Selection Fields
- Select, Multi-Select, Boolean
### Date/Time Fields
- Date, DateTime, Time
### Relationship Fields
- BelongsTo, HasMany, ManyToMany
### Rich Content
- Markdown, Code
### File Fields
- File, Image
### Other
- Color, JSON
## 🔧 Components Installed
Installed from shadcn-vue:
- Table (with all sub-components)
- Checkbox
- Switch
- Textarea
- Calendar
- Popover
- Command
- Badge
- Dialog
## 🚀 How to Use
### 1. View the Demo
```bash
# Start the frontend dev server
cd frontend
npm run dev
# Visit http://localhost:3000/demo/field-views
```
### 2. Use in Your App
```vue
<script setup>
import { ListView } from '@/components/views'
import { FieldType, ViewMode } from '@/types/field-types'
const config = {
objectApiName: 'Contact',
mode: ViewMode.LIST,
fields: [
{
id: '1',
apiName: 'name',
label: 'Name',
type: FieldType.TEXT,
isRequired: true,
},
// ... more fields
],
}
</script>
<template>
<ListView :config="config" :data="records" />
</template>
```
### 3. Integrate with Backend
```typescript
// Frontend
const objectDef = await $fetch('/api/setup/objects/Contact/ui-config')
const listConfig = buildListViewConfig(objectDef)
// Backend - the endpoint returns properly formatted field configs
GET /api/setup/objects/{objectApiName}/ui-config
```
## 🗃️ Database Changes
Run the migration to add UI metadata support:
```bash
cd backend
npm run migrate:tenant
```
This adds a `ui_metadata` JSONB column to the `field_definitions` table.
## 📋 API Endpoints
### New Endpoint
- `GET /api/setup/objects/:objectApiName/ui-config` - Returns object definition with frontend-ready field configs
### Existing Endpoints
- `GET /api/setup/objects` - List all object definitions
- `GET /api/setup/objects/:objectApiName` - Get object definition
- `POST /api/setup/objects` - Create object definition
- `POST /api/setup/objects/:objectApiName/fields` - Create field definition
## ✨ Features
### ListView
- Sortable columns
- Row selection with bulk actions
- Search functionality
- Custom actions
- Export support
- Responsive design
### DetailView
- Organized sections
- Collapsible sections
- Read-only optimized display
- Custom actions
- Field-type aware rendering
### EditView
- Client-side validation
- Required field indicators
- Help text and placeholders
- Error messages
- Organized sections
- Collapsible sections
### FieldRenderer
- Handles all 20+ field types
- Three rendering modes (list, detail, edit)
- Type-specific components
- Validation support
- Formatting options
## 🔄 Integration with Existing System
The field type system integrates seamlessly with your existing multi-tenant app builder:
1. **Object Definitions** - Uses existing `object_definitions` table
2. **Field Definitions** - Extends existing `field_definitions` table with `ui_metadata`
3. **Runtime Pages** - Dynamic route at `/app/objects/:objectName` automatically renders appropriate views
4. **Composables** - `useFieldViews` provides utilities for mapping backend data
## 📝 Next Steps
1. **Run the migration** to add UI metadata support
2. **Test the demo** at `/demo/field-views`
3. **Integrate with your objects** using the dynamic route
4. **Customize field types** as needed for your use case
5. **Add validation rules** to field definitions
6. **Configure UI metadata** for better UX
## 🎯 Best Practices
1. Always provide clear labels and help text
2. Use validation rules with custom messages
3. Organize fields into logical sections
4. Mark required fields appropriately
5. Use appropriate field types for data
6. Test on mobile devices
7. Use read-only for system fields
## 📚 Documentation
See [FIELD_TYPES_GUIDE.md](./FIELD_TYPES_GUIDE.md) for complete documentation including:
- Detailed usage examples
- Field configuration options
- Validation rules
- Event handling
- Customization guide
- Performance tips
- Accessibility features
## 🐛 Troubleshooting
### Missing UI Metadata
If fields don't render correctly, ensure:
1. Migration has been run
2. `uiMetadata` is populated in database
3. Field types are correctly mapped
### Components Not Found
Ensure all shadcn-vue components are installed:
```bash
cd frontend
npx shadcn-vue@latest add table checkbox switch textarea calendar popover command badge
```
### Type Errors
Ensure TypeScript types are properly imported:
```typescript
import { FieldType, ViewMode, type FieldConfig } from '@/types/field-types'
```
## 💡 Tips
1. Use the `FieldMapperService` to automatically generate UI configs
2. Leverage `useViewState` composable for CRUD operations
3. Customize field rendering by extending `FieldRenderer.vue`
4. Add custom actions to views for workflow automation
5. Use sections to organize complex forms
## 🎉 Success!
You now have a complete, production-ready field type system inspired by Laravel Nova! The system is:
- ✅ Fully typed with TypeScript
- ✅ Responsive and accessible
- ✅ Integrated with your backend
- ✅ Extensible and customizable
- ✅ Well-documented
- ✅ Demo-ready
Happy building! 🚀

View File

@@ -0,0 +1,315 @@
# Multi-Tenant Migration - Implementation Summary
## Overview
The platform has been migrated from a single-database multi-tenant architecture to a **one database per tenant** architecture with subdomain-based tenant identification.
## Architecture Changes
### Database Layer
- **Central Database** (Prisma): Stores tenant metadata, domain mappings, encrypted credentials
- **Tenant Databases** (Knex.js + Objection.js): One MySQL database per tenant with isolated data
### Tenant Identification
- **Before**: `x-tenant-id` header
- **After**: Subdomain extraction from hostname (e.g., `acme.routebox.co` → tenant `acme`)
- **Fallback**: `x-tenant-id` header for local development
### Technology Stack
- **Central DB ORM**: Prisma 5.8.0
- **Tenant DB Migration**: Knex.js 3.x
- **Tenant DB ORM**: Objection.js 3.x
- **Database Driver**: mysql2
## File Structure
### Backend - Tenant Management
```
src/tenant/
├── tenant-database.service.ts # Knex connection manager with encryption
├── tenant-provisioning.service.ts # Create/destroy tenant databases
├── tenant-provisioning.controller.ts # API for tenant provisioning
├── tenant.middleware.ts # Subdomain extraction & tenant injection
└── tenant.module.ts # Module configuration
migrations/tenant/ # Knex migrations for tenant databases
├── 20250126000001_create_users_and_rbac.js
├── 20250126000002_create_object_definitions.js
├── 20250126000003_create_apps.js
└── 20250126000004_create_standard_objects.js
```
### Backend - Models (Objection.js)
```
src/models/
├── base.model.ts # Base model with timestamps
├── user.model.ts # User with roles
├── role.model.ts # Role with permissions
├── permission.model.ts # Permission
├── user-role.model.ts # User-Role join table
├── role-permission.model.ts # Role-Permission join table
├── object-definition.model.ts # Dynamic object metadata
├── field-definition.model.ts # Field metadata
├── app.model.ts # Application
├── app-page.model.ts # Application pages
└── account.model.ts # Standard Account object
```
### Backend - Schema Management
```
src/object/
├── schema-management.service.ts # Dynamic table creation from ObjectDefinitions
└── object.service.ts # Object CRUD operations (needs migration)
```
### Central Database Schema (Prisma)
```
prisma/
├── schema-central.prisma # Tenant, Domain models
└── migrations/ # Will be created when generating
```
## Setup Instructions
### 1. Environment Configuration
Copy `.env.example` to `.env` and configure:
```bash
cd /root/neo/backend
cp .env.example .env
```
Generate encryption key:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Update `.env` with the generated key and database URLs:
```env
CENTRAL_DATABASE_URL="mysql://user:password@platform-db:3306/central_platform"
ENCRYPTION_KEY="<generated-32-byte-hex-key>"
DB_ROOT_USER="root"
DB_ROOT_PASSWORD="root"
```
### 2. Central Database Setup
Generate Prisma client and run migrations:
```bash
cd /root/neo/backend
npx prisma generate --schema=./prisma/schema-central.prisma
npx prisma migrate dev --schema=./prisma/schema-central.prisma --name init
```
### 3. Tenant Provisioning
Create a new tenant via API:
```bash
curl -X POST http://localhost:3000/setup/tenants \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corporation",
"slug": "acme",
"primaryDomain": "acme"
}'
```
This will:
1. Create MySQL database `tenant_acme`
2. Create database user `tenant_acme_user`
3. Run all Knex migrations on the new database
4. Seed default roles and permissions
5. Store encrypted credentials in central database
6. Create domain mapping (`acme` → tenant)
### 4. Testing Subdomain Routing
Update your hosts file or DNS to point subdomains to your server:
```
127.0.0.1 acme.localhost
127.0.0.1 demo.localhost
```
Access the application:
- Central setup: `http://localhost:3000/setup/tenants`
- Tenant app: `http://acme.localhost:3000/`
- Different tenant: `http://demo.localhost:3000/`
## Migration Status
### ✅ Completed
- [x] Central database schema (Tenant, Domain models)
- [x] Knex + Objection.js installation
- [x] TenantDatabaseService with dynamic connections
- [x] Password encryption/decryption (AES-256-CBC)
- [x] Base Objection.js models (User, Role, Permission, etc.)
- [x] Knex migrations for base tenant schema
- [x] Tenant middleware with subdomain extraction
- [x] Tenant provisioning service (create/destroy)
- [x] Schema management service (dynamic table creation)
### 🔄 Pending
- [ ] Generate Prisma client for central database
- [ ] Run Prisma migrations for central database
- [ ] Migrate AuthService from Prisma to Objection.js
- [ ] Migrate RBACService from Prisma to Objection.js
- [ ] Migrate ObjectService from Prisma to Objection.js
- [ ] Migrate AppBuilderService from Prisma to Objection.js
- [ ] Update frontend to work with subdomains
- [ ] Test tenant provisioning flow
- [ ] Test subdomain routing
- [ ] Test database isolation
## Service Migration Guide
### Example: Migrating a Service from Prisma to Objection
**Before (Prisma):**
```typescript
async findUser(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
```
**After (Objection + Knex):**
```typescript
constructor(private readonly tenantDbService: TenantDatabaseService) {}
async findUser(tenantId: string, email: string) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
return User.query(knex).findOne({ email });
}
```
### Key Changes
1. Inject `TenantDatabaseService` instead of `PrismaService`
2. Get tenant Knex connection: `await this.tenantDbService.getTenantKnex(tenantId)`
3. Use Objection models: `User.query(knex).findOne({ email })`
4. Pass `tenantId` to all service methods (extract from request in controller)
## API Changes
### Tenant Provisioning Endpoints
**Create Tenant**
```
POST /setup/tenants
Content-Type: application/json
{
"name": "Company Name",
"slug": "company-slug",
"primaryDomain": "company",
"dbHost": "platform-db", // optional
"dbPort": 3306 // optional
}
Response:
{
"tenantId": "uuid",
"dbName": "tenant_company-slug",
"dbUsername": "tenant_company-slug_user",
"dbPassword": "generated-password"
}
```
**Delete Tenant**
```
DELETE /setup/tenants/:tenantId
Response:
{
"success": true
}
```
## Security Considerations
1. **Encryption**: Tenant database passwords are encrypted with AES-256-CBC before storage
2. **Isolation**: Each tenant has a dedicated MySQL database and user
3. **Credentials**: Database credentials stored in central DB, never exposed to tenants
4. **Subdomain Validation**: Middleware validates tenant exists and is active before processing requests
## Troubleshooting
### Connection Issues
Check tenant connection cache:
```typescript
await this.tenantDbService.disconnectTenant(tenantId);
const knex = await this.tenantDbService.getTenantKnex(tenantId); // Fresh connection
```
### Migration Issues
Run migrations manually:
```bash
cd /root/neo/backend
npx knex migrate:latest --knexfile=knexfile.js
```
### Encryption Key Issues
If `ENCRYPTION_KEY` is not set, generate one:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
## Next Steps
1. **Generate Central DB Schema**
```bash
npx prisma generate --schema=./prisma/schema-central.prisma
npx prisma migrate dev --schema=./prisma/schema-central.prisma
```
2. **Migrate Existing Services**
- Start with `AuthService` (most critical)
- Then `RBACService`, `ObjectService`, `AppBuilderService`
- Update all controllers to extract `tenantId` from request
3. **Frontend Updates**
- Update API calls to include subdomain
- Test cross-tenant isolation
- Update login flow to redirect to tenant subdomain
4. **Testing**
- Create multiple test tenants
- Verify data isolation
- Test subdomain routing
- Performance testing with multiple connections
5. **Production Deployment**
- Set up wildcard DNS for subdomains
- Configure SSL certificates for subdomains
- Set up database backup strategy per tenant
- Monitor connection pool usage

View File

@@ -0,0 +1,115 @@
# Multi-Tenant Migration Guide
## Overview
This guide walks you through migrating existing services from the single-database architecture to the new multi-database per-tenant architecture.
## Architecture Comparison
### Before (Single Database)
```typescript
// Single Prisma client, data segregated by tenantId column
@Injectable()
export class UserService {
constructor(private prisma: PrismaService) {}
async findUserByEmail(tenantId: string, email: string) {
return this.prisma.user.findFirst({
where: { tenantId, email },
});
}
}
```
### After (Multi-Database)
```typescript
// Dynamic Knex connection per tenant, complete database isolation
@Injectable()
export class UserService {
constructor(private tenantDb: TenantDatabaseService) {}
async findUserByEmail(tenantId: string, email: string) {
const knex = await this.tenantDb.getTenantKnex(tenantId);
return User.query(knex).findOne({ email });
}
}
```
## Step-by-Step Service Migration Examples
See full examples in the file for:
- AuthService migration
- RBACService migration
- ObjectService migration
- Controller updates
- Common query patterns
- Testing strategies
## Quick Reference
### Query Patterns
**Simple Query**
```typescript
// Prisma
const user = await this.prisma.user.findUnique({ where: { tenantId, id } });
// Objection
const knex = await this.tenantDb.getTenantKnex(tenantId);
const user = await User.query(knex).findById(id);
```
**Query with Relations**
```typescript
// Prisma
const user = await this.prisma.user.findUnique({
where: { tenantId, id },
include: { roles: { include: { permissions: true } } },
});
// Objection
const user = await User.query(knex)
.findById(id)
.withGraphFetched("roles.permissions");
```
**Create**
```typescript
// Prisma
const user = await this.prisma.user.create({ data: { ... } });
// Objection
const user = await User.query(knex).insert({ ... });
```
**Update**
```typescript
// Prisma
const user = await this.prisma.user.update({ where: { id }, data: { ... } });
// Objection
const user = await User.query(knex).patchAndFetchById(id, { ... });
```
**Delete**
```typescript
// Prisma
await this.prisma.user.delete({ where: { id } });
// Objection
await User.query(knex).deleteById(id);
```
## Resources
- [Knex.js Documentation](https://knexjs.org)
- [Objection.js Documentation](https://vincit.github.io/objection.js)
- [MULTI_TENANT_IMPLEMENTATION.md](./MULTI_TENANT_IMPLEMENTATION.md) - Full implementation details

Some files were not shown because too many files have changed in this diff Show More