diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index 26fec40..add4327 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -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; + }>, + ) { + 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); diff --git a/backend/src/object/schema-management.service.ts b/backend/src/object/schema-management.service.ts index 7f932b5..2130c00 100644 --- a/backend/src/object/schema-management.service.ts +++ b/backend/src/object/schema-management.service.ts @@ -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; diff --git a/backend/src/object/setup-object.controller.ts b/backend/src/object/setup-object.controller.ts index 426376c..5da5f93 100644 --- a/backend/src/object/setup-object.controller.ts +++ b/backend/src/object/setup-object.controller.ts @@ -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, diff --git a/frontend/app.vue b/frontend/app.vue index 7f8da09..a1afb46 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,11 +1,13 @@ diff --git a/frontend/components/AIChatBar.vue b/frontend/components/AIChatBar.vue index 90db51a..0e491b8 100644 --- a/frontend/components/AIChatBar.vue +++ b/frontend/components/AIChatBar.vue @@ -21,7 +21,7 @@ const handleSend = () => {