import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; 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'; import { ObjectMetadata } from './models/dynamic-model.factory'; import { MeilisearchService } from '../search/meilisearch.service'; type SearchFilter = { field: string; operator: string; value?: any; values?: any[]; from?: string; to?: string; }; type SearchSort = { field: string; direction: 'asc' | 'desc'; }; type SearchPagination = { page?: number; pageSize?: number; }; @Injectable() export class ObjectService { private readonly logger = new Logger(ObjectService.name); constructor( private tenantDbService: TenantDatabaseService, private customMigrationService: CustomMigrationService, private modelService: ModelService, private authService: AuthorizationService, private meilisearchService: MeilisearchService, ) {} // Setup endpoints - Object metadata management async getObjectDefinitions(tenantId: string) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const objects = await knex('object_definitions') .select('object_definitions.*') .orderBy('label', 'asc'); // Fetch app information for objects that have app_id for (const obj of objects) { if (obj.app_id) { const app = await knex('apps') .where({ id: obj.app_id }) .select('id', 'slug', 'label', 'description') .first(); obj.app = app; } } return objects; } async getObjectDefinition(tenantId: string, apiName: string) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const obj = await knex('object_definitions') .where({ apiName }) .first(); if (!obj) { throw new NotFoundException(`Object ${apiName} not found`); } // Get fields for this object const fields = await knex('field_definitions') .where({ objectDefinitionId: obj.id }) .orderBy('label', 'asc'); // Normalize all fields to ensure system fields are properly marked and add any missing system fields const normalizedFields = this.addMissingSystemFields( fields.map((field: any) => this.normalizeField(field)), ); // Get app information if object belongs to an app let app = null; if (obj.app_id) { app = await knex('apps') .where({ id: obj.app_id }) .select('id', 'slug', 'label', 'description') .first(); } const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, apiName); return { ...obj, fields: normalizedFields, app, relatedLists, }; } async createObjectDefinition( tenantId: string, data: { apiName: string; label: string; pluralLabel?: string; description?: string; isSystem?: boolean; }, ) { // Resolve tenant ID in case a slug was passed const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Generate UUID for the new object const objectId = require('crypto').randomUUID(); // Create the object definition record await knex('object_definitions').insert({ id: objectId, ...data, created_at: knex.fn.now(), updated_at: knex.fn.now(), }); const objectDef = await knex('object_definitions').where({ id: objectId }).first(); // Create standard field definitions (only if they don't already exist) const standardFields = [ { apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', description: 'The user who owns this record', isRequired: false, // Auto-set by system isUnique: false, referenceObject: 'User', isSystem: true, isCustom: false, }, { apiName: 'name', label: 'Name', type: 'STRING', description: 'The primary name field for this record', isRequired: false, // Optional field isUnique: false, referenceObject: null, isSystem: false, isCustom: false, }, { apiName: 'created_at', label: 'Created At', type: 'DATE_TIME', description: 'The timestamp when this record was created', isRequired: false, // Auto-set by system isUnique: false, referenceObject: null, isSystem: true, isCustom: false, }, { apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME', description: 'The timestamp when this record was last updated', isRequired: false, // Auto-set by system isUnique: false, referenceObject: null, isSystem: true, isCustom: false, }, ]; // Insert standard field definitions that don't already exist for (const field of standardFields) { const existingField = await knex('field_definitions') .where({ objectDefinitionId: objectDef.id, apiName: field.apiName, }) .first(); if (!existingField) { const fieldData: any = { id: knex.raw('(UUID())'), objectDefinitionId: objectDef.id, ...field, created_at: knex.fn.now(), updated_at: knex.fn.now(), }; // For lookup fields, set ui_metadata with relationDisplayField if (field.type === 'LOOKUP') { fieldData.ui_metadata = JSON.stringify({ relationDisplayField: 'name', }); } await knex('field_definitions').insert(fieldData); } } // Create a migration to create the table const tableName = this.getTableName(data.apiName, data.label, data.pluralLabel); const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName); try { await this.customMigrationService.createAndExecuteMigration( knex, resolvedTenantId, { name: `create_${tableName}_table`, description: `Create table for ${data.label} object`, type: 'create_table', sql: createTableSQL, }, ); } catch (error) { // Log the error but don't fail - migration is recorded for future retry console.error(`Failed to execute table creation migration: ${error.message}`); } // Create and register the Objection model for this object try { const allFields = await knex('field_definitions') .where({ objectDefinitionId: objectDef.id }) .select('apiName', 'label', 'type', 'isRequired', 'isUnique', 'referenceObject'); const objectMetadata: ObjectMetadata = { apiName: data.apiName, tableName, fields: allFields.map((f: any) => ({ apiName: f.apiName, label: f.label, type: f.type, isRequired: f.isRequired, isUnique: f.isUnique, referenceObject: f.referenceObject, })), relations: [], }; await this.modelService.createModelForObject(resolvedTenantId, objectMetadata); } catch (error) { console.error(`Failed to create model for object ${data.apiName}:`, error.message); } return objectDef; } async updateObjectDefinition( tenantId: string, objectApiName: string, data: Partial<{ label: string; pluralLabel: string; description: string; orgWideDefault: 'private' | 'public_read' | 'public_read_write'; }>, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Update the object definition await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .patch({ ...data, updatedAt: new Date(), }); // Return updated object return await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); } async createFieldDefinition( tenantId: string, objectApiName: string, data: { apiName: string; label: string; type: string; description?: string; isRequired?: boolean; isUnique?: boolean; referenceObject?: string; relationObject?: string; relationDisplayField?: string; relationObjects?: string[]; relationTypeField?: string; defaultValue?: string; length?: number; precision?: number; scale?: number; uiMetadata?: any; }, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const obj = await this.getObjectDefinition(tenantId, objectApiName); // Convert frontend type to database type const dbFieldType = this.convertFrontendFieldType(data.type); // Use relationObject if provided (alias for referenceObject) const referenceObject = data.referenceObject || data.relationObject; // Generate UUID in Node.js instead of using MySQL UUID() function const fieldId = require('crypto').randomUUID(); const fieldData: any = { id: fieldId, objectDefinitionId: obj.id, apiName: data.apiName, label: data.label, type: dbFieldType, description: data.description, isRequired: data.isRequired ?? false, isUnique: data.isUnique ?? false, referenceObject: referenceObject, defaultValue: data.defaultValue, 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 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); 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, 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 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 createdField; } // Helper to get table name from object definition 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; } /** * Normalize field definition to ensure system fields are properly marked */ private normalizeField(field: any): any { const systemFieldNames = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt']; const isSystemField = systemFieldNames.includes(field.apiName); return { ...field, // Ensure system fields are marked correctly isSystem: isSystemField ? true : field.isSystem, isRequired: isSystemField ? false : field.isRequired, isCustom: isSystemField ? false : field.isCustom ?? true, }; } /** * Convert frontend field type to database field type */ private convertFrontendFieldType(frontendType: string): string { const typeMap: Record = { 'text': 'TEXT', 'textarea': 'LONG_TEXT', 'password': 'TEXT', 'email': 'EMAIL', 'number': 'NUMBER', 'currency': 'CURRENCY', 'percent': 'PERCENT', 'select': 'PICKLIST', 'multiSelect': 'MULTI_PICKLIST', 'boolean': 'BOOLEAN', 'date': 'DATE', 'datetime': 'DATE_TIME', 'time': 'TIME', 'url': 'URL', 'color': 'TEXT', 'json': 'JSON', 'lookup': 'LOOKUP', 'belongsTo': 'LOOKUP', 'hasMany': 'LOOKUP', 'manyToMany': 'LOOKUP', 'markdown': 'LONG_TEXT', 'code': 'LONG_TEXT', 'file': 'FILE', 'image': 'IMAGE', }; return typeMap[frontendType] || 'TEXT'; } /** * Ensure a model is registered for the given object. * Delegates to ModelService which handles creating the model and all its dependencies. */ private async ensureModelRegistered( tenantId: string, objectApiName: string, 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( objectDef.apiName, objectDef.label, objectDef.pluralLabel, ); // Build relations from lookup fields, but only for models that exist const lookupFields = objectDef.fields.filter((f: any) => f.type === 'LOOKUP' && f.referenceObject ); // Filter to only include relations where we can successfully resolve the target const validRelations: any[] = []; for (const field of lookupFields) { // Check if the referenced object will be available // We'll let the recursive registration attempt it, but won't include failed ones validRelations.push({ name: field.apiName.replace(/Id$/, '').toLowerCase(), type: 'belongsTo' as const, targetObjectApiName: field.referenceObject, fromColumn: field.apiName, toColumn: 'id', }); } const relatedLists = await this.getRelatedListDefinitions(tenantId, apiName); for (const relatedList of relatedLists) { validRelations.push({ name: relatedList.relationName, type: 'hasMany' as const, targetObjectApiName: relatedList.objectApiName, fromColumn: 'id', toColumn: relatedList.lookupFieldApiName, }); } return { apiName, tableName, fields: objectDef.fields.map((f: any) => ({ apiName: f.apiName, label: f.label, type: f.type, isRequired: f.isRequired, isUnique: f.isUnique, referenceObject: f.referenceObject, })), relations: validRelations, }; }; // Let the ModelService handle recursive model creation try { await this.modelService.ensureModelWithDependencies( tenantId, objectDefinition?.apiName || objectApiName, fetchMetadata, ); } catch (error) { this.logger.warn( `Failed to ensure model for ${objectApiName}: ${error.message}. Will fall back to manual hydration.`, ); } } private async buildAuthorizedQuery( tenantId: string, objectApiName: string, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); if (!user) { throw new NotFoundException('User not found'); } const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .withGraphFetched('fields'); if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } // Normalize and enrich fields to include system fields for downstream permissions/search const normalizedFields = this.addMissingSystemFields( (objectDefModel.fields || []).map((field: any) => this.normalizeField(field)), ); objectDefModel.fields = normalizedFields; await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query(); await this.authService.applyScopeToQuery( query, objectDefModel, user, 'read', knex, ); const lookupFields = objectDefModel.fields?.filter((field) => field.type === 'LOOKUP' && field.referenceObject, ) || []; if (lookupFields.length > 0) { const relationExpression = lookupFields .map((field) => field.apiName.replace(/Id$/, '').toLowerCase()) .filter(Boolean) .join(', '); if (relationExpression) { query = query.withGraphFetched(`[${relationExpression}]`); } } return { query, objectDefModel, user, knex, }; } private applySearchFilters( query: any, filters: SearchFilter[], validFields: Set, ) { if (!Array.isArray(filters) || filters.length === 0) { return query; } for (const filter of filters) { const field = filter?.field; if (!field || !validFields.has(field)) { continue; } const operator = String(filter.operator || 'eq').toLowerCase(); const value = filter.value; const values = Array.isArray(filter.values) ? filter.values : undefined; switch (operator) { case 'eq': query.where(field, value); break; case 'neq': query.whereNot(field, value); break; case 'gt': query.where(field, '>', value); break; case 'gte': query.where(field, '>=', value); break; case 'lt': query.where(field, '<', value); break; case 'lte': query.where(field, '<=', value); break; case 'contains': if (value !== undefined && value !== null) { query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}%`]); } break; case 'startswith': if (value !== undefined && value !== null) { query.whereRaw('LOWER(??) like ?', [field, `${String(value).toLowerCase()}%`]); } break; case 'endswith': if (value !== undefined && value !== null) { query.whereRaw('LOWER(??) like ?', [field, `%${String(value).toLowerCase()}`]); } break; case 'in': if (values?.length) { query.whereIn(field, values); } else if (Array.isArray(value)) { query.whereIn(field, value); } break; case 'notin': if (values?.length) { query.whereNotIn(field, values); } else if (Array.isArray(value)) { query.whereNotIn(field, value); } break; case 'isnull': query.whereNull(field); break; case 'notnull': query.whereNotNull(field); break; case 'between': { const from = filter.from ?? (Array.isArray(value) ? value[0] : undefined); const to = filter.to ?? (Array.isArray(value) ? value[1] : undefined); if (from !== undefined && to !== undefined) { query.whereBetween(field, [from, to]); } else if (from !== undefined) { query.where(field, '>=', from); } else if (to !== undefined) { query.where(field, '<=', to); } break; } default: break; } } return query; } private applyKeywordFilter( query: any, keyword: string, fields: string[], ) { const trimmed = keyword?.trim(); if (!trimmed || fields.length === 0) { return query; } query.where((builder: any) => { const lowered = trimmed.toLowerCase(); for (const field of fields) { builder.orWhereRaw('LOWER(??) like ?', [field, `%${lowered}%`]); } }); return query; } private async finalizeRecordQuery( query: any, objectDefModel: any, user: any, pagination?: SearchPagination, ) { const parsedPage = Number.isFinite(Number(pagination?.page)) ? Number(pagination?.page) : 1; const parsedPageSize = Number.isFinite(Number(pagination?.pageSize)) ? Number(pagination?.pageSize) : 0; const safePage = parsedPage > 0 ? parsedPage : 1; const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0; const shouldPaginate = safePageSize > 0; const totalCount = await query.clone().resultSize(); if (shouldPaginate) { query = query.offset((safePage - 1) * safePageSize).limit(safePageSize); } const records = await query.select('*'); const filteredRecords = await Promise.all( records.map((record: any) => this.authService.filterReadableFields(record, objectDefModel.fields, user), ), ); return { data: filteredRecords, totalCount, page: shouldPaginate ? safePage : 1, pageSize: shouldPaginate ? safePageSize : filteredRecords.length, }; } // Runtime endpoints - CRUD operations async getRecords( tenantId: string, objectApiName: string, userId: string, filters?: any, ) { let { query, objectDefModel, user } = await this.buildAuthorizedQuery( tenantId, objectApiName, userId, ); // Extract pagination and sorting parameters from query string const { page, pageSize, sortField, sortDirection, ...rawFilters } = filters || {}; const reservedFilterKeys = new Set(['page', 'pageSize', 'sortField', 'sortDirection']); const filterEntries = Object.entries(rawFilters || {}).filter( ([key, value]) => !reservedFilterKeys.has(key) && value !== undefined && value !== null && value !== '', ); if (filterEntries.length > 0) { query = query.where(builder => { for (const [key, value] of filterEntries) { builder.where(key, value as any); } }); } if (sortField) { const direction = sortDirection === 'desc' ? 'desc' : 'asc'; query = query.orderBy(sortField, direction); } return this.finalizeRecordQuery(query, objectDefModel, user, { page, pageSize }); } async searchRecordsByIds( tenantId: string, objectApiName: string, userId: string, recordIds: string[], pagination?: SearchPagination, ) { if (!Array.isArray(recordIds) || recordIds.length === 0) { return { data: [], totalCount: 0, page: pagination?.page ?? 1, pageSize: pagination?.pageSize ?? 0, }; } const { query, objectDefModel, user } = await this.buildAuthorizedQuery( tenantId, objectApiName, userId, ); query.whereIn('id', recordIds); const orderBindings: any[] = ['id']; const cases = recordIds .map((id, index) => { orderBindings.push(id, index); return 'when ? then ?'; }) .join(' '); if (cases) { query.orderByRaw(`case ?? ${cases} end`, orderBindings); } return this.finalizeRecordQuery(query, objectDefModel, user, pagination); } async searchRecordsWithFilters( tenantId: string, objectApiName: string, userId: string, filters: SearchFilter[], pagination?: SearchPagination, sort?: SearchSort, ) { const { query, objectDefModel, user } = await this.buildAuthorizedQuery( tenantId, objectApiName, userId, ); const validFields = new Set([ ...(objectDefModel.fields?.map((field: any) => field.apiName) || []), ...this.getSystemFieldNames(), ]); this.applySearchFilters(query, filters, validFields); if (sort?.field && validFields.has(sort.field)) { query.orderBy(sort.field, sort.direction === 'desc' ? 'desc' : 'asc'); } return this.finalizeRecordQuery(query, objectDefModel, user, pagination); } async searchRecordsByKeyword( tenantId: string, objectApiName: string, userId: string, keyword: string, pagination?: SearchPagination, ) { const { query, objectDefModel, user } = await this.buildAuthorizedQuery( tenantId, objectApiName, userId, ); const keywordFields = (objectDefModel.fields || []) .filter((field: any) => this.isKeywordField(field.type)) .map((field: any) => field.apiName); this.applyKeywordFilter(query, keyword, keywordFields); return this.finalizeRecordQuery(query, objectDefModel, user, pagination); } async getRecord( tenantId: string, objectApiName: string, recordId: string, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get user with roles and permissions const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); if (!user) { throw new NotFoundException('User not found'); } // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .withGraphFetched('fields'); if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } // Ensure model is registered await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query().where({ id: recordId }); // Apply authorization scope (modifies query in place) await this.authService.applyScopeToQuery( query, objectDefModel, user, 'read', knex, ); // Build graph expression for lookup fields const lookupFields = objectDefModel.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject ) || []; const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, objectApiName); const relationNames = [ ...lookupFields .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .filter(Boolean), ...relatedLists.map(list => list.relationName), ]; if (relationNames.length > 0) { const relationExpression = relationNames.join(', '); query = query.withGraphFetched(`[${relationExpression}]`); } const record = await query.first(); if (!record) { throw new NotFoundException('Record not found'); } // Filter fields based on field-level permissions const filteredRecord = await this.authService.filterReadableFields(record, objectDefModel.fields, user); return filteredRecord; } private async getRelatedListDefinitions( tenantId: string, objectApiName: string, ): Promise> { const knex = await this.tenantDbService.getTenantKnexById(tenantId); const relatedLookupsRaw = await knex('field_definitions as fd') .join('object_definitions as od', 'fd.objectDefinitionId', 'od.id') .where('fd.type', 'LOOKUP') .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 []; } const objectIds = Array.from( new Set(relatedLookups.map((lookup: any) => lookup.objectDefinitionId)), ); const relatedFields = await knex('field_definitions') .whereIn('objectDefinitionId', objectIds) .orderBy('label', 'asc'); const fieldsByObject = new Map(); for (const field of relatedFields) { const existing = fieldsByObject.get(field.objectDefinitionId) || []; existing.push(this.normalizeField(field)); fieldsByObject.set(field.objectDefinitionId, existing); } const lookupCounts = relatedLookups.reduce>( (acc, lookup: any) => { acc[lookup.childApiName] = (acc[lookup.childApiName] || 0) + 1; return acc; }, {}, ); return relatedLookups.map((lookup: any) => { 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()}` : baseRelationName; const baseTitle = lookup.childPluralLabel || (lookup.childLabel ? `${lookup.childLabel}s` : lookup.childApiName); const title = hasMultiple ? `${baseTitle} (${lookup.fieldLabel})` : baseTitle; return { title, relationName, objectApiName: lookup.childApiName, lookupFieldApiName: lookup.fieldApiName, parentObjectApiName: objectApiName, fields: fieldsByObject.get(lookup.objectDefinitionId) || [], }; }); } async createRecord( tenantId: string, objectApiName: string, data: any, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get user with roles and permissions const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); if (!user) { throw new NotFoundException('User not found'); } // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .withGraphFetched('fields'); if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } // Check if user has create permission const canCreate = await this.authService.canCreate(objectDefModel, user); if (!canCreate) { throw new NotFoundException('You do not have permission to create records of this object'); } // Filter data to only editable fields const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); // Ensure model is registered 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, ...(hasOwnerField ? { ownerId: userId } : {}), }; const normalizedRecordData = await this.normalizePolymorphicRelatedObject( resolvedTenantId, objectApiName, recordData, ); const record = await boundModel.query().insert(normalizedRecordData); await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); return record; } async updateRecord( tenantId: string, objectApiName: string, recordId: string, data: any, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get user with roles and permissions const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); if (!user) { throw new NotFoundException('User not found'); } // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }) .withGraphFetched('fields'); if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } const tableName = this.getTableName( objectDefModel.apiName, objectDefModel.label, objectDefModel.pluralLabel, ); // Get existing record const existingRecord = await knex(tableName).where({ id: recordId }).first(); if (!existingRecord) { throw new NotFoundException('Record not found'); } // Check if user can update this record await this.authService.assertCanPerformAction('update', objectDefModel, existingRecord, user, knex); // Filter data to only editable fields const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); // Remove system fields delete editableData.id; delete editableData.ownerId; delete editableData.created_at; delete editableData.tenantId; // Ensure model is registered await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const normalizedEditableData = await this.normalizePolymorphicRelatedObject( resolvedTenantId, objectApiName, editableData, ); // Use patch to avoid validating or overwriting fields that aren't present in the edit view await boundModel.query().patch(normalizedEditableData).where({ id: recordId }); const record = await boundModel.query().where({ id: recordId }).first(); await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record); return record; } async deleteRecord( tenantId: string, objectApiName: string, recordId: string, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get user with roles and permissions const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); if (!user) { throw new NotFoundException('User not found'); } // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } const tableName = this.getTableName( objectDefModel.apiName, objectDefModel.label, objectDefModel.pluralLabel, ); // Get existing record const existingRecord = await knex(tableName).where({ id: recordId }).first(); if (!existingRecord) { throw new NotFoundException('Record not found'); } // Check if user can delete this record await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex); // Ensure model is registered await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); await boundModel.query().where({ id: recordId }).delete(); await this.removeIndexedRecord(resolvedTenantId, objectApiName, recordId); return { success: true }; } async deleteRecords( tenantId: string, objectApiName: string, recordIds: string[], userId: string, ) { if (!Array.isArray(recordIds) || recordIds.length === 0) { throw new BadRequestException('No record IDs provided'); } const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get user with roles and permissions const user = await User.query(knex) .findById(userId) .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); if (!user) { throw new NotFoundException('User not found'); } // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException(`Object ${objectApiName} not found`); } const tableName = this.getTableName( objectDefModel.apiName, objectDefModel.label, objectDefModel.pluralLabel, ); const records = await knex(tableName).whereIn('id', recordIds); if (records.length === 0) { throw new NotFoundException('No records found to delete'); } const foundIds = new Set(records.map((record: any) => record.id)); const missingIds = recordIds.filter(id => !foundIds.has(id)); if (missingIds.length > 0) { throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`); } const deletableIds: string[] = []; const deniedIds: string[] = []; for (const record of records) { const canDelete = await this.authService.canPerformAction( 'delete', objectDefModel, record, user, knex, ); if (canDelete) { deletableIds.push(record.id); } else { deniedIds.push(record.id); } } // Ensure model is registered await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel); // Use Objection model const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); if (deletableIds.length > 0) { await boundModel.query().whereIn('id', deletableIds).delete(); } // Remove from search index await Promise.all( deletableIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id), ), ); return { success: true, deleted: deletableIds.length, deletedIds: deletableIds, deniedIds, }; } private async indexRecord( tenantId: string, objectApiName: string, fields: FieldDefinition[], record: Record, ) { if (!this.meilisearchService.isEnabled() || !record?.id) return; const fieldsToIndex = (fields || []) .map((field: any) => field.apiName) .filter((apiName) => apiName && !this.isSystemField(apiName)); console.log('Indexing record', { tenantId, objectApiName, recordId: record.id, fieldsToIndex, }); await this.meilisearchService.upsertRecord( tenantId, objectApiName, record, fieldsToIndex, ); console.log('Indexed record successfully'); const meiliResults = await this.meilisearchService.searchRecords( tenantId, objectApiName, record.name, { limit: 10 }, ); console.log('Meilisearch results:', meiliResults); } private async removeIndexedRecord( tenantId: string, objectApiName: string, recordId: string, ) { if (!this.meilisearchService.isEnabled()) return; await this.meilisearchService.deleteRecord(tenantId, objectApiName, recordId); } private isSystemField(apiName: string): boolean { return this.getSystemFieldNames().includes(apiName); } private getSystemFieldNames(): string[] { return ['id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId']; } private addMissingSystemFields(fields: any[]): any[] { const existing = new Map((fields || []).map((field) => [field.apiName, field])); const systemDefaults = [ { apiName: 'id', label: 'ID', type: 'STRING' }, { apiName: 'created_at', label: 'Created At', type: 'DATE_TIME' }, { apiName: 'updated_at', label: 'Updated At', type: 'DATE_TIME' }, { apiName: 'ownerId', label: 'Owner', type: 'LOOKUP', referenceObject: 'User' }, ]; const merged = [...fields]; for (const sysField of systemDefaults) { if (!existing.has(sysField.apiName)) { merged.push({ ...sysField, isSystem: true, isCustom: false, isRequired: false, }); } } return merged; } private isKeywordField(type: string | undefined): boolean { const normalized = String(type || '').toUpperCase(); return [ 'STRING', 'TEXT', 'EMAIL', 'PHONE', 'URL', 'RICH_TEXT', 'TEXTAREA', ].includes(normalized); } private async normalizePolymorphicRelatedObject( tenantId: string, objectApiName: string, data: any, ): Promise { if (!data || !this.isContactDetailApi(objectApiName)) return data; const relatedObjectType = data.relatedObjectType; const relatedObjectId = data.relatedObjectId; if (!relatedObjectType || !relatedObjectId) return data; const normalizedType = this.toPolymorphicApiName(relatedObjectType); if (!normalizedType) return data; if (this.isUuid(String(relatedObjectId))) { return { ...data, relatedObjectType: normalizedType, }; } let targetDefinition: any; try { targetDefinition = await this.getObjectDefinition(tenantId, normalizedType.toLowerCase()); } catch (error) { this.logger.warn(`Failed to load definition for ${normalizedType}: ${error.message}`); } if (!targetDefinition) { throw new BadRequestException( `Unable to resolve ${normalizedType} for "${relatedObjectId}". Please provide a valid record.`, ); } const displayField = this.getDisplayFieldForObjectDefinition(targetDefinition); const tableName = this.getTableName( targetDefinition.apiName, targetDefinition.label, targetDefinition.pluralLabel, ); let resolvedId: string | null = null; if (this.meilisearchService.isEnabled()) { const match = await this.meilisearchService.searchRecord( tenantId, targetDefinition.apiName, String(relatedObjectId), displayField, ); if (match?.id) { resolvedId = match.id; } } if (!resolvedId) { const knex = await this.tenantDbService.getTenantKnexById(tenantId); const record = await knex(tableName) .whereRaw('LOWER(??) = ?', [displayField, String(relatedObjectId).toLowerCase()]) .first(); if (record?.id) { resolvedId = record.id; } } if (!resolvedId) { throw new BadRequestException( `Could not find ${normalizedType} matching "${relatedObjectId}". Please use an existing record.`, ); } return { ...data, relatedObjectId: resolvedId, relatedObjectType: normalizedType, }; } private isContactDetailApi(objectApiName: string): boolean { if (!objectApiName) return false; const normalized = objectApiName.toLowerCase(); return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes( normalized, ); } private toPolymorphicApiName(raw: string): string | null { if (!raw) return null; const normalized = raw.toLowerCase(); if (normalized === 'account' || normalized === 'accounts') return 'Account'; if (normalized === 'contact' || normalized === 'contacts') return 'Contact'; return null; } private isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( value || '', ); } private getDisplayFieldForObjectDefinition(objectDefinition: any): string { if (!objectDefinition?.fields) return 'id'; const hasName = objectDefinition.fields.some((field: any) => field.apiName === 'name'); if (hasName) return 'name'; const firstText = objectDefinition.fields.find((field: any) => ['STRING', 'TEXT', 'EMAIL'].includes(String(field.type || '').toUpperCase()), ); return firstText?.apiName || 'id'; } /** * 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( objectDef.apiName, objectDef.label, objectDef.pluralLabel, ); 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( objectDef.apiName, objectDef.label, objectDef.pluralLabel, ); const schemaManagementService = new SchemaManagementService(); try { 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 } // 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); // Get all field permissions for this object's fields const permissions = await knex('role_field_permissions as rfp') .join('field_definitions as fd', 'fd.id', 'rfp.fieldDefinitionId') .where('fd.objectDefinitionId', objectId) .select('rfp.*'); return permissions; } async updateFieldPermission( tenantId: string, roleId: string, fieldDefinitionId: string, canRead: boolean, canEdit: boolean, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Check if permission already exists const existing = await knex('role_field_permissions') .where({ roleId, fieldDefinitionId }) .first(); if (existing) { // Update existing permission await knex('role_field_permissions') .where({ roleId, fieldDefinitionId }) .update({ canRead, canEdit, updated_at: knex.fn.now(), }); } else { // Create new permission await knex('role_field_permissions').insert({ id: knex.raw('(UUID())'), roleId, fieldDefinitionId, canRead, canEdit, created_at: knex.fn.now(), updated_at: knex.fn.now(), }); } return { success: true }; } async getObjectPermissions( tenantId: string, objectApiName: string, roleId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get object definition const objectDef = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDef) { throw new NotFoundException(`Object ${objectApiName} not found`); } // Get role object permissions const permission = await knex('role_object_permissions') .where({ roleId, objectDefinitionId: objectDef.id }) .first(); if (!permission) { // Return default permissions (all false) return { canCreate: false, canRead: false, canEdit: false, canDelete: false, canViewAll: false, canModifyAll: false, }; } return { canCreate: Boolean(permission.canCreate), canRead: Boolean(permission.canRead), canEdit: Boolean(permission.canEdit), canDelete: Boolean(permission.canDelete), canViewAll: Boolean(permission.canViewAll), canModifyAll: Boolean(permission.canModifyAll), }; } async updateObjectPermissions( tenantId: string, objectApiName: string, data: { roleId: string; canCreate: boolean; canRead: boolean; canEdit: boolean; canDelete: boolean; canViewAll: boolean; canModifyAll: boolean; }, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Get object definition const objectDef = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDef) { throw new NotFoundException(`Object ${objectApiName} not found`); } // Check if permission already exists const existing = await knex('role_object_permissions') .where({ roleId: data.roleId, objectDefinitionId: objectDef.id }) .first(); if (existing) { // Update existing permission await knex('role_object_permissions') .where({ roleId: data.roleId, objectDefinitionId: objectDef.id }) .update({ canCreate: data.canCreate, canRead: data.canRead, canEdit: data.canEdit, canDelete: data.canDelete, canViewAll: data.canViewAll, canModifyAll: data.canModifyAll, updated_at: knex.fn.now(), }); } else { // Create new permission await knex('role_object_permissions').insert({ id: knex.raw('(UUID())'), roleId: data.roleId, objectDefinitionId: objectDef.id, canCreate: data.canCreate, canRead: data.canRead, canEdit: data.canEdit, canDelete: data.canDelete, canViewAll: data.canViewAll, canModifyAll: data.canModifyAll, created_at: knex.fn.now(), updated_at: knex.fn.now(), }); } return { success: true }; } }