From ca11c8cbe76055d10ddd9952cd82f4d48d1c111a Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Mon, 12 Jan 2026 21:08:47 +0100 Subject: [PATCH] WIP - add contct and contact details --- ...001_create_contacts_and_contact_details.js | 26 ++- ...pdate_contact_detail_polymorphic_fields.js | 101 ++++++++++ ...003_make_contact_detail_fields_editable.js | 45 +++++ backend/src/models/contact-detail.model.ts | 22 ++- backend/src/models/field-definition.model.ts | 2 + backend/src/object/field-mapper.service.ts | 4 + backend/src/object/models/model.registry.ts | 11 +- backend/src/object/object.service.ts | 175 +++++++++++++----- .../src/object/schema-management.service.ts | 59 ++++-- backend/src/rbac/ability.factory.ts | 5 +- backend/src/rbac/record-sharing.controller.ts | 60 ++++-- frontend/components/PageLayoutRenderer.vue | 13 ++ frontend/components/fields/FieldRenderer.vue | 14 ++ frontend/components/fields/LookupField.vue | 68 ++++++- .../components/views/DetailViewEnhanced.vue | 14 +- frontend/components/views/EditView.vue | 11 ++ .../components/views/EditViewEnhanced.vue | 11 ++ frontend/composables/useFieldViews.ts | 2 + frontend/types/field-types.ts | 2 + 19 files changed, 551 insertions(+), 94 deletions(-) create mode 100644 backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js create mode 100644 backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js diff --git a/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js b/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js index 1ce78be..1b4176d 100644 --- a/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js +++ b/backend/migrations/tenant/20250305000001_create_contacts_and_contact_details.js @@ -107,18 +107,23 @@ exports.up = async function (knex) { (await knex('object_definitions').where('apiName', 'ContactDetail').first()) .id; + const contactDetailRelationObjects = ['Account', 'Contact'] + await knex('field_definitions').insert([ { id: knex.raw('(UUID())'), objectDefinitionId: contactDetailObjectDefId, apiName: 'relatedObjectType', label: 'Related Object Type', - type: 'String', + type: 'PICKLIST', length: 100, isRequired: true, - isSystem: true, + isSystem: false, isCustom: false, displayOrder: 1, + ui_metadata: JSON.stringify({ + options: contactDetailRelationObjects.map((value) => ({ label: value, value })), + }), created_at: knex.fn.now(), updated_at: knex.fn.now(), }, @@ -127,12 +132,17 @@ exports.up = async function (knex) { objectDefinitionId: contactDetailObjectDefId, apiName: 'relatedObjectId', label: 'Related Object ID', - type: 'String', + type: 'LOOKUP', length: 36, isRequired: true, - isSystem: true, + isSystem: false, isCustom: false, displayOrder: 2, + ui_metadata: JSON.stringify({ + relationObjects: contactDetailRelationObjects, + relationTypeField: 'relatedObjectType', + relationDisplayField: 'name', + }), created_at: knex.fn.now(), updated_at: knex.fn.now(), }, @@ -144,7 +154,7 @@ exports.up = async function (knex) { type: 'String', length: 50, isRequired: true, - isSystem: true, + isSystem: false, isCustom: false, displayOrder: 3, created_at: knex.fn.now(), @@ -157,7 +167,7 @@ exports.up = async function (knex) { label: 'Label', type: 'String', length: 100, - isSystem: true, + isSystem: false, isCustom: false, displayOrder: 4, created_at: knex.fn.now(), @@ -170,7 +180,7 @@ exports.up = async function (knex) { label: 'Value', type: 'Text', isRequired: true, - isSystem: true, + isSystem: false, isCustom: false, displayOrder: 5, created_at: knex.fn.now(), @@ -182,7 +192,7 @@ exports.up = async function (knex) { apiName: 'isPrimary', label: 'Primary', type: 'Boolean', - isSystem: true, + isSystem: false, isCustom: false, displayOrder: 6, created_at: knex.fn.now(), diff --git a/backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js b/backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js new file mode 100644 index 0000000..bea8f3c --- /dev/null +++ b/backend/migrations/tenant/20250305000002_update_contact_detail_polymorphic_fields.js @@ -0,0 +1,101 @@ +exports.up = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + const relationObjects = ['Account', 'Contact']; + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectType', + }) + .update({ + type: 'PICKLIST', + length: 100, + isSystem: false, + ui_metadata: JSON.stringify({ + options: relationObjects.map((value) => ({ label: value, value })), + }), + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectId', + }) + .update({ + type: 'LOOKUP', + length: 36, + isSystem: false, + ui_metadata: JSON.stringify({ + relationObjects, + relationTypeField: 'relatedObjectType', + relationDisplayField: 'name', + }), + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .whereIn('apiName', [ + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .andWhere({ objectDefinitionId: contactDetailObject.id }) + .update({ + isSystem: false, + updated_at: knex.fn.now(), + }); +}; + +exports.down = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectType', + }) + .update({ + type: 'String', + length: 100, + isSystem: true, + ui_metadata: null, + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .where({ + objectDefinitionId: contactDetailObject.id, + apiName: 'relatedObjectId', + }) + .update({ + type: 'String', + length: 36, + isSystem: true, + ui_metadata: null, + updated_at: knex.fn.now(), + }); + + await knex('field_definitions') + .whereIn('apiName', [ + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .andWhere({ objectDefinitionId: contactDetailObject.id }) + .update({ + isSystem: true, + updated_at: knex.fn.now(), + }); +}; diff --git a/backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js b/backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js new file mode 100644 index 0000000..f9e4d5d --- /dev/null +++ b/backend/migrations/tenant/20250305000003_make_contact_detail_fields_editable.js @@ -0,0 +1,45 @@ +exports.up = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + await knex('field_definitions') + .where({ objectDefinitionId: contactDetailObject.id }) + .whereIn('apiName', [ + 'relatedObjectType', + 'relatedObjectId', + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .update({ + isSystem: false, + updated_at: knex.fn.now(), + }); +}; + +exports.down = async function (knex) { + const contactDetailObject = await knex('object_definitions') + .where({ apiName: 'ContactDetail' }) + .first(); + + if (!contactDetailObject) return; + + await knex('field_definitions') + .where({ objectDefinitionId: contactDetailObject.id }) + .whereIn('apiName', [ + 'relatedObjectType', + 'relatedObjectId', + 'detailType', + 'label', + 'value', + 'isPrimary', + ]) + .update({ + isSystem: true, + updated_at: knex.fn.now(), + }); +}; diff --git a/backend/src/models/contact-detail.model.ts b/backend/src/models/contact-detail.model.ts index e57c70e..0587cf2 100644 --- a/backend/src/models/contact-detail.model.ts +++ b/backend/src/models/contact-detail.model.ts @@ -4,10 +4,30 @@ export class ContactDetail extends BaseModel { static tableName = 'contact_details'; id!: string; - relatedObjectType!: string; + relatedObjectType!: 'Account' | 'Contact'; relatedObjectId!: string; detailType!: string; label?: string; value!: string; isPrimary!: boolean; + + // Provide optional relations for each supported parent type. + static relationMappings = { + account: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'account.model', + join: { + from: 'contact_details.relatedObjectId', + to: 'accounts.id', + }, + }, + contact: { + relation: BaseModel.BelongsToOneRelation, + modelClass: 'contact.model', + join: { + from: 'contact_details.relatedObjectId', + to: 'contacts.id', + }, + }, + }; } diff --git a/backend/src/models/field-definition.model.ts b/backend/src/models/field-definition.model.ts index 661e389..d63bb19 100644 --- a/backend/src/models/field-definition.model.ts +++ b/backend/src/models/field-definition.model.ts @@ -30,6 +30,8 @@ export interface UIMetadata { step?: number; // For number accept?: string; // For file/image relationDisplayField?: string; // Which field to display for relations + relationObjects?: string[]; // For polymorphic relations + relationTypeField?: string; // Field API name storing the selected relation type // Formatting format?: string; // Date format, number format, etc. diff --git a/backend/src/object/field-mapper.service.ts b/backend/src/object/field-mapper.service.ts index 3eb1d94..345c00d 100644 --- a/backend/src/object/field-mapper.service.ts +++ b/backend/src/object/field-mapper.service.ts @@ -22,7 +22,9 @@ export interface FieldConfigDTO { step?: number; accept?: string; relationObject?: string; + relationObjects?: string[]; relationDisplayField?: string; + relationTypeField?: string; format?: string; prefix?: string; suffix?: string; @@ -106,10 +108,12 @@ export class FieldMapperService { step: uiMetadata.step, accept: uiMetadata.accept, relationObject: field.referenceObject, + relationObjects: uiMetadata.relationObjects, // For lookup fields, provide default display field if not specified relationDisplayField: isLookupField ? (uiMetadata.relationDisplayField || 'name') : uiMetadata.relationDisplayField, + relationTypeField: uiMetadata.relationTypeField, // Formatting format: uiMetadata.format, diff --git a/backend/src/object/models/model.registry.ts b/backend/src/object/models/model.registry.ts index cd728ef..8ee2d02 100644 --- a/backend/src/object/models/model.registry.ts +++ b/backend/src/object/models/model.registry.ts @@ -16,13 +16,17 @@ export class ModelRegistry { */ registerModel(apiName: string, modelClass: ModelClass): void { this.registry.set(apiName, modelClass); + const lowerKey = apiName.toLowerCase(); + if (lowerKey !== apiName && !this.registry.has(lowerKey)) { + this.registry.set(lowerKey, modelClass); + } } /** * Get a model from the registry */ getModel(apiName: string): ModelClass { - const model = this.registry.get(apiName); + const model = this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()); if (!model) { throw new Error(`Model for ${apiName} not found in registry`); } @@ -33,7 +37,7 @@ export class ModelRegistry { * Check if a model exists in the registry */ hasModel(apiName: string): boolean { - return this.registry.has(apiName); + return this.registry.has(apiName) || this.registry.has(apiName.toLowerCase()); } /** @@ -46,7 +50,8 @@ export class ModelRegistry { // Returns undefined if model not found (for models not yet registered) const model = DynamicModelFactory.createModel( metadata, - (apiName: string) => this.registry.get(apiName), + (apiName: string) => + this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()), ); this.registerModel(metadata.apiName, model); return model; diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index e923aeb..d15cb21 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -187,7 +187,7 @@ export class ObjectService { } // Create a migration to create the table - const tableName = this.getTableName(data.apiName); + const tableName = this.getTableName(data.apiName, data.label, data.pluralLabel); const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName); try { @@ -273,6 +273,8 @@ export class ObjectService { referenceObject?: string; relationObject?: string; relationDisplayField?: string; + relationObjects?: string[]; + relationTypeField?: string; defaultValue?: string; length?: number; precision?: number; @@ -311,16 +313,13 @@ export class ObjectService { updated_at: knex.fn.now(), }; - // Merge UI metadata - const uiMetadata: any = {}; - if (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); + // Store relationDisplayField in UI metadata if provided + if (data.relationDisplayField || data.relationObjects || data.relationTypeField) { + fieldData.ui_metadata = JSON.stringify({ + relationDisplayField: data.relationDisplayField, + relationObjects: data.relationObjects, + relationTypeField: data.relationTypeField, + }); } await knex('field_definitions').insert(fieldData); @@ -329,7 +328,13 @@ export class ObjectService { // Add the column to the physical table const schemaManagementService = new SchemaManagementService(); try { - await schemaManagementService.addFieldToTable(knex, objectApiName, createdField); + await schemaManagementService.addFieldToTable( + knex, + obj.apiName, + createdField, + obj.label, + obj.pluralLabel, + ); 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 @@ -342,22 +347,37 @@ export class ObjectService { } // 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'; + private getTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); + + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(objectApiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + // Prefer the label-derived name when it introduces clearer word boundaries + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + + // Otherwise fall back to label/plural if they differ from API-derived + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + + return fromApi; } /** @@ -417,11 +437,16 @@ export class ObjectService { private async ensureModelRegistered( tenantId: string, objectApiName: string, + objectDefinition?: any, ): Promise { // Provide a metadata fetcher function that the ModelService can use const fetchMetadata = async (apiName: string): Promise => { const objectDef = await this.getObjectDefinition(tenantId, apiName); - const tableName = this.getTableName(apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); // Build relations from lookup fields, but only for models that exist const lookupFields = objectDef.fields.filter((f: any) => @@ -472,7 +497,7 @@ export class ObjectService { try { await this.modelService.ensureModelWithDependencies( tenantId, - objectApiName, + objectDefinition?.apiName || objectApiName, fetchMetadata, ); } catch (error) { @@ -510,10 +535,14 @@ export class ObjectService { throw new NotFoundException(`Object ${objectApiName} not found`); } - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDefModel.apiName, + objectDefModel.label, + objectDefModel.pluralLabel, + ); // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); @@ -590,7 +619,7 @@ export class ObjectService { } // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); @@ -648,19 +677,50 @@ export class ObjectService { }>> { const knex = await this.tenantDbService.getTenantKnexById(tenantId); - const relatedLookups = await knex('field_definitions as fd') + const relatedLookupsRaw = await knex('field_definitions as fd') .join('object_definitions as od', 'fd.objectDefinitionId', 'od.id') .where('fd.type', 'LOOKUP') - .andWhere('fd.referenceObject', objectApiName) .select( 'fd.apiName as fieldApiName', 'fd.label as fieldLabel', 'fd.objectDefinitionId as objectDefinitionId', + 'fd.referenceObject as referenceObject', + 'fd.ui_metadata as uiMetadata', 'od.apiName as childApiName', 'od.label as childLabel', 'od.pluralLabel as childPluralLabel', ); + const relatedLookups = relatedLookupsRaw + .map((lookup: any) => { + let uiMetadata: any = {}; + if (lookup.uiMetadata) { + try { + uiMetadata = typeof lookup.uiMetadata === 'string' + ? JSON.parse(lookup.uiMetadata) + : lookup.uiMetadata; + } catch { + uiMetadata = {}; + } + } + return { ...lookup, uiMetadata }; + }) + .filter((lookup: any) => { + const target = (objectApiName || '').toLowerCase(); + const referenceMatch = + typeof lookup.referenceObject === 'string' && + lookup.referenceObject.toLowerCase() === target; + + if (referenceMatch) return true; + + const relationObjects = lookup.uiMetadata?.relationObjects; + if (!Array.isArray(relationObjects)) return false; + + return relationObjects.some( + (rel: string) => typeof rel === 'string' && rel.toLowerCase() === target, + ); + }); + if (relatedLookups.length === 0) { return []; } @@ -689,7 +749,11 @@ export class ObjectService { ); return relatedLookups.map((lookup: any) => { - const baseRelationName = this.getTableName(lookup.childApiName); + const baseRelationName = this.getTableName( + lookup.childApiName, + lookup.childLabel, + lookup.childPluralLabel, + ); const hasMultiple = lookupCounts[lookup.childApiName] > 1; const relationName = hasMultiple ? `${baseRelationName}_${lookup.fieldApiName.replace(/Id$/, '').toLowerCase()}` @@ -747,13 +811,14 @@ export class ObjectService { const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); + const hasOwnerField = objectDefModel.fields?.some((f: any) => f.apiName === 'ownerId'); const recordData = { ...editableData, - ownerId: userId, // Auto-set owner + ...(hasOwnerField ? { ownerId: userId } : {}), }; const record = await boundModel.query().insert(recordData); return record; @@ -787,7 +852,11 @@ export class ObjectService { throw new NotFoundException(`Object ${objectApiName} not found`); } - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDefModel.apiName, + objectDefModel.label, + objectDefModel.pluralLabel, + ); // Get existing record const existingRecord = await knex(tableName).where({ id: recordId }).first(); @@ -808,7 +877,7 @@ export class ObjectService { delete editableData.tenantId; // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); @@ -842,7 +911,11 @@ export class ObjectService { throw new NotFoundException(`Object ${objectApiName} not found`); } - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDefModel.apiName, + objectDefModel.label, + objectDefModel.pluralLabel, + ); // Get existing record const existingRecord = await knex(tableName).where({ id: recordId }).first(); @@ -854,7 +927,7 @@ export class ObjectService { await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex); // Ensure model is registered - await this.ensureModelRegistered(resolvedTenantId, objectApiName); + await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); @@ -905,7 +978,11 @@ export class ObjectService { } // Check if this field has data (count records) - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const recordCount = await knex(tableName).count('* as cnt').first(); const hasData = recordCount && (recordCount.cnt as number) > 0; @@ -1060,11 +1137,21 @@ export class ObjectService { } // Remove the column from the physical table - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const schemaManagementService = new SchemaManagementService(); try { - await schemaManagementService.removeFieldFromTable(knex, objectApiName, fieldApiName); + await schemaManagementService.removeFieldFromTable( + knex, + objectDef.apiName, + fieldApiName, + objectDef.label, + objectDef.pluralLabel, + ); } 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 diff --git a/backend/src/object/schema-management.service.ts b/backend/src/object/schema-management.service.ts index 2130c00..39b79cc 100644 --- a/backend/src/object/schema-management.service.ts +++ b/backend/src/object/schema-management.service.ts @@ -15,7 +15,11 @@ export class SchemaManagementService { objectDefinition: ObjectDefinition, fields: FieldDefinition[], ) { - const tableName = this.getTableName(objectDefinition.apiName); + const tableName = this.getTableName( + objectDefinition.apiName, + objectDefinition.label, + objectDefinition.pluralLabel, + ); // Check if table already exists const exists = await knex.schema.hasTable(tableName); @@ -44,8 +48,10 @@ export class SchemaManagementService { knex: Knex, objectApiName: string, field: FieldDefinition, + objectLabel?: string, + pluralLabel?: string, ) { - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); await knex.schema.alterTable(tableName, (table) => { this.addFieldColumn(table, field); @@ -61,8 +67,10 @@ export class SchemaManagementService { knex: Knex, objectApiName: string, fieldApiName: string, + objectLabel?: string, + pluralLabel?: string, ) { - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); await knex.schema.alterTable(tableName, (table) => { table.dropColumn(fieldApiName); @@ -81,11 +89,13 @@ export class SchemaManagementService { objectApiName: string, fieldApiName: string, field: FieldDefinition, + objectLabel?: string, + pluralLabel?: string, options?: { skipTypeChange?: boolean; // Skip if type change would lose data }, ) { - const tableName = this.getTableName(objectApiName); + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); const skipTypeChange = options?.skipTypeChange ?? true; await knex.schema.alterTable(tableName, (table) => { @@ -105,8 +115,8 @@ export class SchemaManagementService { /** * Drop an object table */ - async dropObjectTable(knex: Knex, objectApiName: string) { - const tableName = this.getTableName(objectApiName); + async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) { + const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel); await knex.schema.dropTableIfExists(tableName); @@ -241,16 +251,35 @@ export class SchemaManagementService { /** * 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(/^_/, ''); + private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); - // Simple pluralization (append 's' if not already plural) - // In production, use a proper pluralization library - return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(apiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; + } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + + return fromApi; } /** diff --git a/backend/src/rbac/ability.factory.ts b/backend/src/rbac/ability.factory.ts index 4e1fc28..673848e 100644 --- a/backend/src/rbac/ability.factory.ts +++ b/backend/src/rbac/ability.factory.ts @@ -180,8 +180,9 @@ export class AbilityFactory { } } - // Field permissions exist but this field is not explicitly granted → deny - return false; + // No explicit rule for this field but other field permissions exist. + // Default to allow so new fields don't get silently stripped and fail validation. + return true; } /** diff --git a/backend/src/rbac/record-sharing.controller.ts b/backend/src/rbac/record-sharing.controller.ts index 5f5b7f0..0f1877b 100644 --- a/backend/src/rbac/record-sharing.controller.ts +++ b/backend/src/rbac/record-sharing.controller.ts @@ -45,7 +45,11 @@ export class RecordSharingController { } // Get the record to check ownership - const tableName = this.getTableName(objectDef.apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const record = await knex(tableName) .where({ id: recordId }) .first(); @@ -109,7 +113,11 @@ export class RecordSharingController { } // Get the record to check ownership - const tableName = this.getTableName(objectDef.apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const record = await knex(tableName) .where({ id: recordId }) .first(); @@ -207,7 +215,11 @@ export class RecordSharingController { } // Get the record to check ownership - const tableName = this.getTableName(objectDef.apiName); + const tableName = this.getTableName( + objectDef.apiName, + objectDef.label, + objectDef.pluralLabel, + ); const record = await knex(tableName) .where({ id: recordId }) .first(); @@ -305,20 +317,34 @@ export class RecordSharingController { return false; } - private getTableName(apiName: string): string { - // Convert CamelCase to snake_case and pluralize - const snakeCase = apiName - .replace(/([A-Z])/g, '_$1') - .toLowerCase() - .replace(/^_/, ''); - - // Simple pluralization - if (snakeCase.endsWith('y')) { - return snakeCase.slice(0, -1) + 'ies'; - } else if (snakeCase.endsWith('s')) { - return snakeCase + 'es'; - } else { - return snakeCase + 's'; + private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string { + const toSnakePlural = (source: string): string => { + const cleaned = source.replace(/[\s-]+/g, '_'); + const snake = cleaned + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/__+/g, '_') + .toLowerCase() + .replace(/^_/, ''); + + if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; + if (snake.endsWith('s')) return snake; + return `${snake}s`; + }; + + const fromApi = toSnakePlural(apiName); + const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; + const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; + + if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { + return fromLabel; } + if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { + return fromPlural; + } + + if (fromLabel && fromLabel !== fromApi) return fromLabel; + if (fromPlural && fromPlural !== fromApi) return fromPlural; + + return fromApi; } } diff --git a/frontend/components/PageLayoutRenderer.vue b/frontend/components/PageLayoutRenderer.vue index 4d4eb8e..a275e09 100644 --- a/frontend/components/PageLayoutRenderer.vue +++ b/frontend/components/PageLayoutRenderer.vue @@ -17,6 +17,7 @@ :record-data="modelValue" :mode="readonly ? VM.DETAIL : VM.EDIT" @update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)" + @update:related-fields="handleRelatedFieldsUpdate" /> @@ -34,6 +35,7 @@ :record-data="modelValue" :mode="readonly ? VM.DETAIL : VM.EDIT" @update:model-value="handleFieldUpdate(field.apiName, $event)" + @update:related-fields="handleRelatedFieldsUpdate" /> @@ -96,6 +98,17 @@ const handleFieldUpdate = (fieldName: string, value: any) => { emit('update:modelValue', updated) } + +const handleRelatedFieldsUpdate = (values: Record) => { + if (props.readonly) return + + const updated = { + ...props.modelValue, + ...values, + } + + emit('update:modelValue', updated) +}