Merge branch 'drawer' into codex/add-contact-and-contact-details-objects
This commit is contained in:
@@ -3,6 +3,7 @@ import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||
import { ModelService } from './models/model.service';
|
||||
import { AuthorizationService } from '../rbac/authorization.service';
|
||||
import { SchemaManagementService } from './schema-management.service';
|
||||
import { ObjectDefinition } from '../models/object-definition.model';
|
||||
import { FieldDefinition } from '../models/field-definition.model';
|
||||
import { User } from '../models/user.model';
|
||||
@@ -270,6 +271,10 @@ export class ObjectService {
|
||||
relationObject?: string;
|
||||
relationDisplayField?: string;
|
||||
defaultValue?: string;
|
||||
length?: number;
|
||||
precision?: number;
|
||||
scale?: number;
|
||||
uiMetadata?: any;
|
||||
},
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
@@ -282,8 +287,11 @@ export class ObjectService {
|
||||
// Use relationObject if provided (alias for referenceObject)
|
||||
const referenceObject = data.referenceObject || data.relationObject;
|
||||
|
||||
// Generate UUID in Node.js instead of using MySQL UUID() function
|
||||
const fieldId = require('crypto').randomUUID();
|
||||
|
||||
const fieldData: any = {
|
||||
id: knex.raw('(UUID())'),
|
||||
id: fieldId,
|
||||
objectDefinitionId: obj.id,
|
||||
apiName: data.apiName,
|
||||
label: data.label,
|
||||
@@ -293,20 +301,41 @@ export class ObjectService {
|
||||
isUnique: data.isUnique ?? false,
|
||||
referenceObject: referenceObject,
|
||||
defaultValue: data.defaultValue,
|
||||
length: data.length,
|
||||
precision: data.precision,
|
||||
scale: data.scale,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
};
|
||||
|
||||
// Store relationDisplayField in UI metadata if provided
|
||||
// Merge UI metadata
|
||||
const uiMetadata: any = {};
|
||||
if (data.relationDisplayField) {
|
||||
fieldData.ui_metadata = JSON.stringify({
|
||||
relationDisplayField: data.relationDisplayField,
|
||||
});
|
||||
uiMetadata.relationDisplayField = data.relationDisplayField;
|
||||
}
|
||||
if (data.uiMetadata) {
|
||||
Object.assign(uiMetadata, data.uiMetadata);
|
||||
}
|
||||
if (Object.keys(uiMetadata).length > 0) {
|
||||
fieldData.ui_metadata = JSON.stringify(uiMetadata);
|
||||
}
|
||||
|
||||
const [id] = await knex('field_definitions').insert(fieldData);
|
||||
await knex('field_definitions').insert(fieldData);
|
||||
const createdField = await knex('field_definitions').where({ id: fieldId }).first();
|
||||
|
||||
// Add the column to the physical table
|
||||
const schemaManagementService = new SchemaManagementService();
|
||||
try {
|
||||
await schemaManagementService.addFieldToTable(knex, objectApiName, createdField);
|
||||
this.logger.log(`Added column ${data.apiName} to table for object ${objectApiName}`);
|
||||
} catch (error) {
|
||||
// If column creation fails, delete the field definition to maintain consistency
|
||||
this.logger.error(`Failed to add column ${data.apiName}: ${error.message}`);
|
||||
await knex('field_definitions').where({ id: fieldId }).delete();
|
||||
throw new Error(`Failed to create field column: ${error.message}`);
|
||||
}
|
||||
|
||||
return knex('field_definitions').where({ id }).first();
|
||||
return createdField;
|
||||
}
|
||||
|
||||
// Helper to get table name from object definition
|
||||
@@ -365,6 +394,7 @@ export class ObjectService {
|
||||
'url': 'URL',
|
||||
'color': 'TEXT',
|
||||
'json': 'JSON',
|
||||
'lookup': 'LOOKUP',
|
||||
'belongsTo': 'LOOKUP',
|
||||
'hasMany': 'LOOKUP',
|
||||
'manyToMany': 'LOOKUP',
|
||||
@@ -742,6 +772,219 @@ export class ObjectService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field definition
|
||||
* Can update metadata (label, description, placeholder, helpText, etc.) safely
|
||||
* Cannot update apiName or type if field has existing data (prevent data loss)
|
||||
*/
|
||||
async updateFieldDefinition(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
fieldApiName: string,
|
||||
data: Partial<{
|
||||
label: string;
|
||||
description: string;
|
||||
isRequired: boolean;
|
||||
isUnique: boolean;
|
||||
defaultValue: string;
|
||||
placeholder: string;
|
||||
helpText: string;
|
||||
displayOrder: number;
|
||||
uiMetadata: Record<string, any>;
|
||||
}>,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Get the object definition
|
||||
const objectDef = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
if (!objectDef) {
|
||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
// Get the field definition
|
||||
const field = await knex('field_definitions')
|
||||
.where({ objectDefinitionId: objectDef.id, apiName: fieldApiName })
|
||||
.first();
|
||||
|
||||
if (!field) {
|
||||
throw new NotFoundException(`Field ${fieldApiName} not found`);
|
||||
}
|
||||
|
||||
// Check if this field has data (count records)
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
const recordCount = await knex(tableName).count('* as cnt').first();
|
||||
const hasData = recordCount && (recordCount.cnt as number) > 0;
|
||||
|
||||
// Prepare update object
|
||||
const updateData: any = {
|
||||
updated_at: knex.fn.now(),
|
||||
};
|
||||
|
||||
// Always allow these updates
|
||||
if (data.label !== undefined) updateData.label = data.label;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.displayOrder !== undefined) updateData.displayOrder = data.displayOrder;
|
||||
|
||||
// Merge with existing uiMetadata
|
||||
const existingMetadata = field.ui_metadata ? JSON.parse(field.ui_metadata) : {};
|
||||
const newMetadata = { ...existingMetadata };
|
||||
|
||||
if (data.placeholder !== undefined) newMetadata.placeholder = data.placeholder;
|
||||
if (data.helpText !== undefined) newMetadata.helpText = data.helpText;
|
||||
if (data.uiMetadata) {
|
||||
Object.assign(newMetadata, data.uiMetadata);
|
||||
}
|
||||
|
||||
if (Object.keys(newMetadata).length > 0) {
|
||||
updateData.ui_metadata = JSON.stringify(newMetadata);
|
||||
}
|
||||
|
||||
// Conditional updates based on data existence
|
||||
if (data.isRequired !== undefined) {
|
||||
if (hasData && data.isRequired && !field.isRequired) {
|
||||
throw new Error('Cannot make a field required when data exists. Existing records may have null values.');
|
||||
}
|
||||
updateData.isRequired = data.isRequired;
|
||||
}
|
||||
|
||||
if (data.isUnique !== undefined) {
|
||||
if (hasData && data.isUnique && !field.isUnique) {
|
||||
throw new Error('Cannot add unique constraint to field with existing data. Existing records may have duplicate values.');
|
||||
}
|
||||
updateData.isUnique = data.isUnique;
|
||||
}
|
||||
|
||||
// Update the field definition
|
||||
await knex('field_definitions')
|
||||
.where({ id: field.id })
|
||||
.update(updateData);
|
||||
|
||||
return knex('field_definitions').where({ id: field.id }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field definition and clean up dependencies
|
||||
* Removes the column from the physical table
|
||||
* Removes field references from page layouts
|
||||
* CASCADE deletion handles role_field_permissions
|
||||
*/
|
||||
async deleteFieldDefinition(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
fieldApiName: string,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Get the object definition
|
||||
const objectDef = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
if (!objectDef) {
|
||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
// Get the field definition
|
||||
const field = await knex('field_definitions')
|
||||
.where({ objectDefinitionId: objectDef.id, apiName: fieldApiName })
|
||||
.first();
|
||||
|
||||
if (!field) {
|
||||
throw new NotFoundException(`Field ${fieldApiName} not found`);
|
||||
}
|
||||
|
||||
// Prevent deletion of system fields
|
||||
const systemFieldNames = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt'];
|
||||
if (systemFieldNames.includes(fieldApiName)) {
|
||||
throw new Error(`Cannot delete system field: ${fieldApiName}`);
|
||||
}
|
||||
|
||||
// Clean up page layouts - remove field references from layoutConfig
|
||||
try {
|
||||
const pageLayouts = await knex('page_layouts')
|
||||
.where({ object_id: objectDef.id });
|
||||
|
||||
for (const layout of pageLayouts) {
|
||||
// Handle JSON column that might already be parsed
|
||||
let layoutConfig;
|
||||
if (layout.layout_config) {
|
||||
layoutConfig = typeof layout.layout_config === 'string'
|
||||
? JSON.parse(layout.layout_config)
|
||||
: layout.layout_config;
|
||||
} else {
|
||||
layoutConfig = { fields: [] };
|
||||
}
|
||||
|
||||
// Filter out any field references for this field
|
||||
if (layoutConfig.fields) {
|
||||
layoutConfig.fields = layoutConfig.fields.filter(
|
||||
(f: any) => f.fieldId !== field.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the page layout
|
||||
await knex('page_layouts')
|
||||
.where({ id: layout.id })
|
||||
.update({
|
||||
layout_config: JSON.stringify(layoutConfig),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// If page layouts table doesn't exist or query fails, log but continue
|
||||
this.logger.warn(`Could not update page layouts for field deletion: ${error.message}`);
|
||||
}
|
||||
|
||||
// Clean up dependsOn references in other fields
|
||||
const otherFields = await knex('field_definitions')
|
||||
.where({ objectDefinitionId: objectDef.id })
|
||||
.whereNot({ id: field.id });
|
||||
|
||||
for (const otherField of otherFields) {
|
||||
// Handle JSON column that might already be parsed
|
||||
let metadata;
|
||||
if (otherField.ui_metadata) {
|
||||
metadata = typeof otherField.ui_metadata === 'string'
|
||||
? JSON.parse(otherField.ui_metadata)
|
||||
: otherField.ui_metadata;
|
||||
} else {
|
||||
metadata = {};
|
||||
}
|
||||
|
||||
if (metadata.dependsOn && Array.isArray(metadata.dependsOn)) {
|
||||
metadata.dependsOn = metadata.dependsOn.filter(
|
||||
(dep: any) => dep !== field.apiName,
|
||||
);
|
||||
|
||||
await knex('field_definitions')
|
||||
.where({ id: otherField.id })
|
||||
.update({
|
||||
ui_metadata: JSON.stringify(metadata),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the column from the physical table
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
const schemaManagementService = new SchemaManagementService();
|
||||
|
||||
try {
|
||||
await schemaManagementService.removeFieldFromTable(knex, objectApiName, fieldApiName);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to remove column ${fieldApiName} from table ${tableName}: ${error.message}`);
|
||||
// Continue with deletion even if column removal fails - field definition must be cleaned up
|
||||
}
|
||||
|
||||
// Delete the field definition (CASCADE will delete role_field_permissions)
|
||||
await knex('field_definitions').where({ id: field.id }).delete();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getFieldPermissions(tenantId: string, objectId: string) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
@@ -71,6 +71,37 @@ export class SchemaManagementService {
|
||||
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alter a field in an existing object table
|
||||
* Handles safe updates like changing NOT NULL or constraints
|
||||
* Warns about potentially destructive operations
|
||||
*/
|
||||
async alterFieldInTable(
|
||||
knex: Knex,
|
||||
objectApiName: string,
|
||||
fieldApiName: string,
|
||||
field: FieldDefinition,
|
||||
options?: {
|
||||
skipTypeChange?: boolean; // Skip if type change would lose data
|
||||
},
|
||||
) {
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
const skipTypeChange = options?.skipTypeChange ?? true;
|
||||
|
||||
await knex.schema.alterTable(tableName, (table) => {
|
||||
// Drop the existing column and recreate with new definition
|
||||
// Note: This approach works for metadata changes, but type changes may need data migration
|
||||
table.dropColumn(fieldApiName);
|
||||
});
|
||||
|
||||
// Recreate the column with new definition
|
||||
await knex.schema.alterTable(tableName, (table) => {
|
||||
this.addFieldColumn(table, field);
|
||||
});
|
||||
|
||||
this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an object table
|
||||
*/
|
||||
@@ -94,15 +125,30 @@ export class SchemaManagementService {
|
||||
let column: Knex.ColumnBuilder;
|
||||
|
||||
switch (field.type) {
|
||||
// Text types
|
||||
case 'String':
|
||||
case 'TEXT':
|
||||
case 'EMAIL':
|
||||
case 'PHONE':
|
||||
case 'URL':
|
||||
column = table.string(columnName, field.length || 255);
|
||||
break;
|
||||
|
||||
case 'Text':
|
||||
case 'LONG_TEXT':
|
||||
column = table.text(columnName);
|
||||
break;
|
||||
|
||||
case 'PICKLIST':
|
||||
case 'MULTI_PICKLIST':
|
||||
column = table.string(columnName, 255);
|
||||
break;
|
||||
|
||||
// Numeric types
|
||||
case 'Number':
|
||||
case 'NUMBER':
|
||||
case 'CURRENCY':
|
||||
case 'PERCENT':
|
||||
if (field.scale && field.scale > 0) {
|
||||
column = table.decimal(
|
||||
columnName,
|
||||
@@ -115,18 +161,28 @@ export class SchemaManagementService {
|
||||
break;
|
||||
|
||||
case 'Boolean':
|
||||
case 'BOOLEAN':
|
||||
column = table.boolean(columnName).defaultTo(false);
|
||||
break;
|
||||
|
||||
// Date types
|
||||
case 'Date':
|
||||
case 'DATE':
|
||||
column = table.date(columnName);
|
||||
break;
|
||||
|
||||
case 'DateTime':
|
||||
case 'DATE_TIME':
|
||||
column = table.datetime(columnName);
|
||||
break;
|
||||
|
||||
case 'TIME':
|
||||
column = table.time(columnName);
|
||||
break;
|
||||
|
||||
// Relationship types
|
||||
case 'Reference':
|
||||
case 'LOOKUP':
|
||||
column = table.uuid(columnName);
|
||||
if (field.referenceObject) {
|
||||
const refTableName = this.getTableName(field.referenceObject);
|
||||
@@ -134,19 +190,30 @@ export class SchemaManagementService {
|
||||
}
|
||||
break;
|
||||
|
||||
// Email (legacy)
|
||||
case 'Email':
|
||||
column = table.string(columnName, 255);
|
||||
break;
|
||||
|
||||
// Phone (legacy)
|
||||
case 'Phone':
|
||||
column = table.string(columnName, 50);
|
||||
break;
|
||||
|
||||
// Url (legacy)
|
||||
case 'Url':
|
||||
column = table.string(columnName, 255);
|
||||
break;
|
||||
|
||||
// File types
|
||||
case 'FILE':
|
||||
case 'IMAGE':
|
||||
column = table.text(columnName); // Store file path or URL
|
||||
break;
|
||||
|
||||
// JSON
|
||||
case 'Json':
|
||||
case 'JSON':
|
||||
column = table.json(columnName);
|
||||
break;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
@@ -72,6 +73,35 @@ export class SetupObjectController {
|
||||
return this.fieldMapperService.mapFieldToDTO(field);
|
||||
}
|
||||
|
||||
@Put(':objectApiName/fields/:fieldApiName')
|
||||
async updateFieldDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('fieldApiName') fieldApiName: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
const field = await this.objectService.updateFieldDefinition(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
fieldApiName,
|
||||
data,
|
||||
);
|
||||
return this.fieldMapperService.mapFieldToDTO(field);
|
||||
}
|
||||
|
||||
@Delete(':objectApiName/fields/:fieldApiName')
|
||||
async deleteFieldDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('fieldApiName') fieldApiName: string,
|
||||
) {
|
||||
return this.objectService.deleteFieldDefinition(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
fieldApiName,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':objectApiName')
|
||||
async updateObjectDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
|
||||
Reference in New Issue
Block a user