914 lines
30 KiB
TypeScript
914 lines
30 KiB
TypeScript
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<string, string> = {
|
|
'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<any> {
|
|
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<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);
|
|
|
|
// 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 };
|
|
}
|
|
}
|