import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CustomMigrationService } from '../migration/custom-migration.service'; import { ModelService } from './models/model.service'; import { ObjectMetadata } from './models/dynamic-model.factory'; import { applyReadScope, applyUpdateScope, applyDeleteScope } from '../auth/query-scope.util'; import { User } from '../models/user.model'; import { ObjectDefinition } from '../models/object-definition.model'; @Injectable() export class ObjectService { private readonly logger = new Logger(ObjectService.name); constructor( private tenantDbService: TenantDatabaseService, private customMigrationService: CustomMigrationService, private modelService: ModelService, ) {} // 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 const normalizedFields = 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(); } return { ...obj, fields: normalizedFields, app, }; } 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); 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 createFieldDefinition( tenantId: string, objectApiName: string, data: { apiName: string; label: string; type: string; description?: string; isRequired?: boolean; isUnique?: boolean; referenceObject?: string; relationObject?: string; relationDisplayField?: string; defaultValue?: string; }, ) { 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; const fieldData: any = { id: knex.raw('(UUID())'), 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, created_at: knex.fn.now(), updated_at: knex.fn.now(), }; // Store relationDisplayField in UI metadata if provided if (data.relationDisplayField) { fieldData.ui_metadata = JSON.stringify({ relationDisplayField: data.relationDisplayField, }); } const [id] = await knex('field_definitions').insert(fieldData); return knex('field_definitions').where({ id }).first(); } // 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'; } } /** * 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', 'belongsTo': 'LOOKUP', 'hasMany': 'LOOKUP', 'manyToMany': 'LOOKUP', 'markdown': 'LONG_TEXT', 'code': 'LONG_TEXT', 'file': 'FILE', 'image': 'IMAGE', }; return typeMap[frontendType] || 'TEXT'; } /** * Filter incoming data to only include writable fields based on field definitions * Removes system fields and fields that don't exist in the schema */ private async filterWritableFields( tenantId: string, objectApiName: string, data: any, isUpdate: boolean = false, ): Promise { const objectDef = await this.getObjectDefinition(tenantId, objectApiName); const filtered: any = {}; for (const [key, value] of Object.entries(data)) { // Find the field definition const fieldDef = objectDef.fields.find((f: any) => f.apiName === key); if (!fieldDef) { // Field doesn't exist in schema, skip it this.logger.warn(`Field ${key} not found in ${objectApiName} schema, skipping`); continue; } // Skip system fields if (fieldDef.isSystem) { this.logger.debug(`Skipping system field ${key}`); continue; } // Check if field is writable (for authorization) // Support both snake_case (from DB) and camelCase (if mapped) const defaultWritable = fieldDef.default_writable ?? fieldDef.defaultWritable; if (defaultWritable === false || defaultWritable === 0) { this.logger.warn(`Field ${key} is not writable (default_writable = ${defaultWritable}), skipping`); continue; } // For update operations, also skip ID field if (isUpdate && key === 'id') { continue; } // Field is valid and writable, include it filtered[key] = value; } return filtered; } /** * 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, ): 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); // 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', }); } 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, objectApiName, fetchMetadata, ); } catch (error) { this.logger.warn( `Failed to ensure model for ${objectApiName}: ${error.message}. Will fall back to manual hydration.`, ); } } // Runtime endpoints - CRUD operations async getRecords( tenantId: string, objectApiName: string, userId: string, filters?: any, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Verify object exists and get field definitions const objectDef = await this.getObjectDefinition(tenantId, objectApiName); // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException('Object definition not found'); } // Get user model for authorization const user = await User.query(knex).findById(userId).withGraphFetched('roles'); if (!user) { throw new NotFoundException('User not found'); } const tableName = this.getTableName(objectApiName); // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); // Try to use the Objection model if available try { const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query(); // Apply authorization scoping query = applyReadScope(query, user, objectDefModel, knex); // Build graph expression for lookup fields const lookupFields = objectDef.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject ) || []; if (lookupFields.length > 0) { // Build relation expression - use singular lowercase for relation name const relationExpression = lookupFields .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .filter(Boolean) .join(', '); if (relationExpression) { query = query.withGraphFetched(`[${relationExpression}]`); } } // Apply additional filters if (filters) { query = query.where(filters); } return query.select('*'); } } catch (error) { this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`); } // Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet let query = knex(tableName); // Add ownership filter if ownerId field exists (basic fallback) const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { query = query.where({ [`${tableName}.ownerId`]: userId }); } // Apply additional filters if (filters) { query = query.where(filters); } // Get base records const records = await query.select(`${tableName}.*`); // Fetch and attach related records for lookup fields const lookupFields = objectDef.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject ) || []; if (lookupFields.length > 0 && records.length > 0) { for (const field of lookupFields) { const relationName = field.apiName.replace(/Id$/, '').toLowerCase(); const relatedTable = this.getTableName(field.referenceObject); // Get unique IDs to fetch const relatedIds = [...new Set( records .map(r => r[field.apiName]) .filter(Boolean) )]; if (relatedIds.length > 0) { // Fetch all related records in one query const relatedRecords = await knex(relatedTable) .whereIn('id', relatedIds) .select('*'); // Create a map for quick lookup const relatedMap = new Map( relatedRecords.map(r => [r.id, r]) ); // Attach related records to main records for (const record of records) { const relatedId = record[field.apiName]; if (relatedId && relatedMap.has(relatedId)) { record[relationName] = relatedMap.get(relatedId); } } } } } return records; } async getRecord( tenantId: string, objectApiName: string, recordId: string, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Verify object exists and get field definitions const objectDef = await this.getObjectDefinition(tenantId, objectApiName); // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException('Object definition not found'); } // Get user model for authorization const user = await User.query(knex).findById(userId).withGraphFetched('roles'); if (!user) { throw new NotFoundException('User not found'); } const tableName = this.getTableName(objectApiName); // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); // Try to use the Objection model if available try { const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query().where({ id: recordId }); // Apply authorization scoping query = applyReadScope(query, user, objectDefModel, knex); // Build graph expression for lookup fields const lookupFields = objectDef.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject ) || []; if (lookupFields.length > 0) { // Build relation expression - use singular lowercase for relation name const relationExpression = lookupFields .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .filter(Boolean) .join(', '); if (relationExpression) { query = query.withGraphFetched(`[${relationExpression}]`); } } const record = await query.first(); if (!record) { throw new NotFoundException('Record not found or you do not have access'); } return record; } } catch (error) { this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`); } // Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet let query = knex(tableName).where({ [`${tableName}.id`]: recordId }); // Add ownership filter if ownerId field exists (basic fallback) const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { query = query.where({ [`${tableName}.ownerId`]: userId }); } const record = await query.first(); if (!record) { throw new NotFoundException('Record not found or you do not have access'); } // Fetch and attach related records for lookup fields const lookupFields = objectDef.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject ) || []; if (lookupFields.length > 0) { for (const field of lookupFields) { const relationName = field.apiName.replace(/Id$/, '').toLowerCase(); const relatedTable = this.getTableName(field.referenceObject); const relatedId = record[field.apiName]; if (relatedId) { // Fetch the related record const relatedRecord = await knex(relatedTable) .where({ id: relatedId }) .first(); if (relatedRecord) { record[relationName] = relatedRecord; } } } } return record; } async createRecord( tenantId: string, objectApiName: string, data: any, userId: string, ) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Verify object exists await this.getObjectDefinition(tenantId, objectApiName); // Get object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException('Object definition not found'); } // Check create permission if (!objectDefModel.publicCreate) { // Get user with roles to check role-based permissions const user = await User.query(knex).findById(userId).withGraphFetched('roles'); if (!user) { throw new NotFoundException('User not found'); } // TODO: Check role-based create permissions from role_rules // For now, only allow if publicCreate is true throw new ForbiddenException('You do not have permission to create records for this object'); } // Filter data to only include writable fields based on field definitions // Do this BEFORE model registration so both Objection and fallback paths use clean data const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, false); // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); // Try to use the Objection model if available try { const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const recordData = { ...allowedData, ownerId: userId, // Auto-set owner }; const record = await boundModel.query().insert(recordData); return record; } } catch (error) { console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); } // Fallback to raw Knex if model not available const tableName = this.getTableName(objectApiName); const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); const recordData: any = { id: knex.raw('(UUID())'), ...allowedData, // Use filtered data instead of raw data created_at: knex.fn.now(), updated_at: knex.fn.now(), }; if (hasOwner) { recordData.ownerId = userId; } const [id] = await knex(tableName).insert(recordData); return knex(tableName).where({ id }).first(); } 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 object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException('Object definition not found'); } // Get user model for authorization const user = await User.query(knex).findById(userId).withGraphFetched('roles'); if (!user) { throw new NotFoundException('User not found'); } // Filter data to only include writable fields based on field definitions // Do this BEFORE authorization checks so both paths use clean data const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, true); // Verify user has access to read the record first (using authorization scope) const tableName = this.getTableName(objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName); const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let checkQuery = boundModel.query().where({ id: recordId }); checkQuery = applyUpdateScope(checkQuery, user, objectDefModel, knex); const existingRecord = await checkQuery.first(); if (!existingRecord) { throw new ForbiddenException('You do not have permission to update this record'); } this.logger.log(`[UPDATE] Record ID: ${recordId}, Type: ${typeof recordId}`); this.logger.log(`[UPDATE] Existing record ID: ${existingRecord.id}, Type: ${typeof existingRecord.id}`); this.logger.log(`[UPDATE] Allowed data:`, JSON.stringify(allowedData)); const numUpdated = await boundModel.query().where({ id: recordId }).update(allowedData); this.logger.log(`[UPDATE] Number of records updated: ${numUpdated}`); const updatedRecord = await boundModel.query().where({ id: recordId }).first(); this.logger.log(`[UPDATE] Updated record:`, updatedRecord ? 'found' : 'NOT FOUND'); return updatedRecord; } // Fallback to raw Knex with basic ownership check const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner && !objectDefModel.publicUpdate) { const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first(); if (!record) { throw new ForbiddenException('You do not have permission to update this record'); } } await knex(tableName) .where({ id: recordId }) .update({ ...allowedData, updated_at: knex.fn.now() }); // Use filtered data return knex(tableName).where({ id: recordId }).first(); } 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 object definition with authorization settings const objectDefModel = await ObjectDefinition.query(knex) .findOne({ apiName: objectApiName }); if (!objectDefModel) { throw new NotFoundException('Object definition not found'); } // Get user model for authorization const user = await User.query(knex).findById(userId).withGraphFetched('roles'); if (!user) { throw new NotFoundException('User not found'); } const tableName = this.getTableName(objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName); // Try to use the Objection model if available const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); // Check if user has permission to delete this record let checkQuery = boundModel.query().where({ id: recordId }); checkQuery = applyDeleteScope(checkQuery, user, objectDefModel, knex); const existingRecord = await checkQuery.first(); if (!existingRecord) { throw new ForbiddenException('You do not have permission to delete this record'); } await boundModel.query().where({ id: recordId }).delete(); return { success: true }; } // Fallback to raw Knex with basic ownership check const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner && !objectDefModel.publicDelete) { const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first(); if (!record) { throw new ForbiddenException('You do not have permission to delete this record'); } } await knex(tableName).where({ id: recordId }).delete(); return { success: true }; } }