WIP - add contct and contact details
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,13 +16,17 @@ export class ModelRegistry {
|
||||
*/
|
||||
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): 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<BaseModel> {
|
||||
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;
|
||||
|
||||
@@ -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<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);
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user