Files
neo/backend/src/object/object.service.ts

1920 lines
59 KiB
TypeScript

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<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',
'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<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(
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<string>,
) {
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<Array<{
title: string;
relationName: string;
objectApiName: string;
lookupFieldApiName: string;
parentObjectApiName: string;
fields: any[];
}>> {
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<string, any[]>();
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<Record<string, number>>(
(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<string, any>,
) {
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<any> {
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<string, any>;
}>,
) {
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 };
}
}