refactor: Migrate CRUD services to use Common API (#16869)

This PR migrates the workflow CRUD services to use the Common API
(CommonQueryRunners) instead of directly accessing TwentyORM.

## Changes

- Created CommonApiContextBuilderService to build context for Common API
- Migrated CreateRecordService to use CommonCreateOneQueryRunnerService
- Migrated UpdateRecordService to use CommonUpdateOneQueryRunnerService
- Migrated DeleteRecordService to use CommonDeleteOneQueryRunnerService
- Migrated FindRecordsService to use CommonFindManyQueryRunnerService
- Migrated UpsertRecordService to use Common API with upsert flag
- Removed unused get-selected-columns-from-restricted-fields.util.ts
- Updated module dependencies

## Benefits

- Consistent permission checking via Common API
- Query hooks (before/after execution)
- Automatic input transformation
- Same behavior as REST/GraphQL APIs
- Reduced code duplication
This commit is contained in:
Félix Malfait
2025-12-31 10:24:50 +01:00
committed by GitHub
parent 009e7e05f2
commit f8fa709abf
39 changed files with 567 additions and 821 deletions
@@ -30,7 +30,7 @@ describe('ApiKeyService', () => {
const mockApiKey: ApiKeyEntity = {
id: mockApiKeyId,
name: 'Test API Key',
expiresAt: new Date('2025-12-31'),
expiresAt: new Date('2030-12-31'),
revokedAt: undefined,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
@@ -8,7 +8,7 @@ import {
Index,
OneToMany,
PrimaryGeneratedColumn,
Relation,
type Relation,
UpdateDateColumn,
} from 'typeorm';
@@ -10,7 +10,7 @@ import {
Index,
OneToMany,
PrimaryGeneratedColumn,
Relation,
type Relation,
UpdateDateColumn,
} from 'typeorm';
@@ -1,23 +1,27 @@
import { Module } from '@nestjs/common';
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
TwentyORMModule,
RecordPositionModule,
RecordTransformerModule,
WorkflowCommonModule,
CoreCommonApiModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheModule,
UserRoleModule,
ApiKeyModule,
],
providers: [
CommonApiContextBuilderService,
CreateRecordService,
UpdateRecordService,
DeleteRecordService,
@@ -0,0 +1,158 @@
import { Injectable } from '@nestjs/common';
import { type ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
export type CommonApiContext = {
queryRunnerContext: CommonBaseQueryRunnerContext;
selectedFields: CommonSelectedFields;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
objectsPermissions: ObjectsPermissions;
};
@Injectable()
export class CommonApiContextBuilderService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly userRoleService: UserRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
) {}
async build({
authContext,
objectName,
}: {
authContext: WorkspaceAuthContext;
objectName: string;
}): Promise<CommonApiContext> {
const workspaceId = authContext.workspace.id;
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
if (!isDefined(flatObjectMetadataMaps)) {
throw new RecordCrudException(
'Object metadata not found for workspace',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
const objectId = idByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = flatObjectMetadataMaps.byId[objectId];
if (!isDefined(flatObjectMetadata)) {
throw new RecordCrudException(
`Object metadata for ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const objectsPermissions = await this.getObjectsPermissions(authContext);
const restrictedFields =
objectsPermissions[flatObjectMetadata.id]?.restrictedFields ?? {};
const selectedFields = getAllSelectableFields({
restrictedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
});
return {
queryRunnerContext: {
authContext,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular: idByNameSingular,
},
selectedFields,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectsPermissions,
};
}
private async getObjectsPermissions(
authContext: WorkspaceAuthContext,
): Promise<ObjectsPermissions> {
const workspaceId = authContext.workspace.id;
let roleId: string;
if (isDefined(authContext.apiKey)) {
roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
} else if (
isDefined(authContext.application?.defaultServerlessFunctionRoleId)
) {
roleId = authContext.application.defaultServerlessFunctionRoleId;
} else if (isDefined(authContext.userWorkspaceId)) {
const userWorkspaceRoleId =
await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
if (!isDefined(userWorkspaceRoleId)) {
throw new RecordCrudException(
'No role found for user workspace',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
roleId = userWorkspaceRoleId;
} else {
throw new RecordCrudException(
'Invalid auth context - no authentication mechanism found',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
return rolesPermissions[roleId] ?? {};
}
}
@@ -1,158 +1,92 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type CreateRecordParams } from 'src/engine/core-modules/record-crud/types/create-record-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Injectable()
export class CreateRecordService {
private readonly logger = new Logger(CreateRecordService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly recordPositionService: RecordPositionService,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly commonCreateOneRunner: CommonCreateOneQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: CreateRecordParams): Promise<ToolOutput> {
const { objectName, objectRecord, authContext, rolePermissionConfig } =
params;
const workspaceId = authContext.workspace.id;
const { objectName, objectRecord, authContext } = params;
try {
return await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectName,
rolePermissionConfig,
);
objectName,
});
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const objectId = objectIdByNameSingular[objectName];
// Pass createdBy explicitly if provided (for workflows)
// Common API hook will also inject createdBy from authContext if available
const actorMetadata = params.createdBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
};
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
// Clean undefined values from the record data (including nested composite fields)
// This prevents validation errors for partial composite field inputs
const cleanedRecord = removeUndefinedFromRecord(objectRecord);
const dataWithActor = { ...cleanedRecord, createdBy: actorMetadata };
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const position = await this.recordPositionService.buildRecordPosition(
{
value: 'first',
objectMetadata: flatObjectMetadata,
workspaceId,
},
);
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const validObjectRecord = Object.fromEntries(
Object.entries(objectRecord).filter(
([key]) =>
isDefined(fieldIdByName[key]) ||
isDefined(fieldIdByJoinColumnName[key]),
),
);
const transformedObjectRecord =
await this.recordInputTransformerService.process({
recordInput: validObjectRecord,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const restrictedFields =
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const actorMetadata = params.createdBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
};
const insertResult = await repository.insert(
{
...transformedObjectRecord,
position,
createdBy: actorMetadata,
// updatedBy: actorMetadata,
},
undefined,
selectedColumns,
);
const [createdRecord] = insertResult.generatedMaps;
this.logger.log(`Record created successfully in ${objectName}`);
return {
success: true,
message: `Record created successfully in ${objectName}`,
result: createdRecord,
recordReferences: [
{
objectNameSingular: objectName,
recordId: createdRecord.id,
displayName: getRecordDisplayName(
{ ...transformedObjectRecord, ...createdRecord },
flatObjectMetadata,
flatFieldMetadataMaps,
),
},
],
};
const createdRecord = await this.commonCreateOneRunner.execute(
{
data: dataWithActor,
selectedFields,
},
queryRunnerContext,
);
this.logger.log(`Record created successfully in ${objectName}`);
return {
success: true,
message: `Record created successfully in ${objectName}`,
result: createdRecord,
recordReferences: [
{
objectNameSingular: objectName,
recordId: createdRecord.id,
displayName: getRecordDisplayName(
createdRecord,
flatObjectMetadata,
flatFieldMetadataMaps,
),
},
],
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
@@ -3,33 +3,28 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service';
import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type DeleteRecordParams } from 'src/engine/core-modules/record-crud/types/delete-record-params.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Injectable()
export class DeleteRecordService {
private readonly logger = new Logger(DeleteRecordService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly commonDeleteOneRunner: CommonDeleteOneQueryRunnerService,
private readonly commonDestroyOneRunner: CommonDestroyOneQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: DeleteRecordParams): Promise<ToolOutput> {
const {
objectName,
objectRecordId,
authContext,
rolePermissionConfig,
soft = true,
} = params;
const workspaceId = authContext.workspace.id;
const { objectName, objectRecordId, authContext, soft = true } = params;
if (!isDefined(objectRecordId) || !isValidUuid(objectRecordId)) {
return {
@@ -40,90 +35,59 @@ export class DeleteRecordService {
}
try {
return await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectName,
rolePermissionConfig,
);
const { queryRunnerContext, selectedFields, flatObjectMetadata } =
await this.commonApiContextBuilder.build({
authContext,
objectName,
});
const { flatObjectMetadataMaps, objectIdByNameSingular } =
repository.internalContext;
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to delete: Object cannot be deleted by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const objectId = objectIdByNameSingular[objectName];
if (soft) {
const deletedRecord = await this.commonDeleteOneRunner.execute(
{
id: objectRecordId,
selectedFields,
},
queryRunnerContext,
);
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
this.logger.log(`Record soft deleted successfully from ${objectName}`);
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
return {
success: true,
message: `Record soft deleted successfully from ${objectName}`,
result: deletedRecord,
};
} else {
const destroyedRecord = await this.commonDestroyOneRunner.execute(
{
id: objectRecordId,
selectedFields,
},
queryRunnerContext,
);
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to delete: Object cannot be deleted by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
this.logger.log(
`Record permanently deleted successfully from ${objectName}`,
);
const objectRecord = await repository.findOne({
where: {
id: objectRecordId,
},
});
if (!objectRecord) {
throw new RecordCrudException(
`Failed to delete: Record ${objectName} with id ${objectRecordId} not found`,
RecordCrudExceptionCode.RECORD_NOT_FOUND,
);
}
if (soft) {
const columnsToReturnForSoftDelete: string[] = [];
await repository.softDelete(
objectRecordId,
undefined,
columnsToReturnForSoftDelete,
);
this.logger.log(
`Record soft deleted successfully from ${objectName}`,
);
return {
success: true,
message: `Record soft deleted successfully from ${objectName}`,
result: objectRecord,
};
} else {
await repository.remove(objectRecord);
this.logger.log(
`Record permanently deleted successfully from ${objectName}`,
);
return {
success: true,
message: `Record permanently deleted successfully from ${objectName}`,
result: { id: objectRecordId },
};
}
},
);
return {
success: true,
message: `Record permanently deleted successfully from ${objectName}`,
result: destroyedRecord,
};
}
} catch (error) {
if (error instanceof RecordCrudException) {
return {
@@ -1,40 +1,24 @@
import { Injectable, Logger } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { OrderByDirection } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectLiteral } from 'typeorm';
import { OrderByDirection, type ObjectRecord } from 'twenty-shared/types';
import {
type ObjectRecordFilter,
type ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
import { type FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@Injectable()
export class FindRecordsService {
private readonly logger = new Logger(FindRecordsService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly commonFindManyRunner: CommonFindManyQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(
@@ -47,91 +31,57 @@ export class FindRecordsService {
limit,
offset = 0,
authContext,
rolePermissionConfig,
} = params;
const workspaceId = authContext.workspace.id;
try {
return await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectName,
rolePermissionConfig,
);
objectName,
});
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
// Add id to orderBy for consistent pagination
const orderByWithIdCondition: ObjectRecordOrderBy = [
...(orderBy ?? []).filter((item) => item !== undefined),
{ id: OrderByDirection.AscNullsFirst },
];
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
const graphqlQueryParser = new GraphqlQueryParser(
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
const records = await this.getObjectRecords({
objectName,
filter,
orderBy,
limit,
offset,
repository,
graphqlQueryParser,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const totalCount = await this.getTotalCount({
objectName,
filter,
repository,
graphqlQueryParser,
flatObjectMetadata,
flatFieldMetadataMaps,
});
this.logger.log(`Found ${records.length} records in ${objectName}`);
const recordReferences = records.map((record) => ({
objectNameSingular: objectName,
recordId: record.id as string,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
}));
return {
success: true,
message: `Found ${records.length} ${objectName} records`,
result: {
records,
count: totalCount,
},
recordReferences,
};
const { records, totalCount } = await this.commonFindManyRunner.execute(
{
filter,
orderBy: orderByWithIdCondition,
first: limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS,
offset,
selectedFields: { ...selectedFields, totalCount: true },
},
queryRunnerContext,
);
this.logger.log(`Found ${records.length} records in ${objectName}`);
const recordReferences = records.map((record: ObjectRecord) => ({
objectNameSingular: objectName,
recordId: record.id as string,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
}));
return {
success: true,
message: `Found ${records.length} ${objectName} records`,
result: {
records,
count: totalCount,
},
recordReferences,
};
} catch (error) {
this.logger.error(`Failed to find records: ${error}`);
@@ -143,131 +93,4 @@ export class FindRecordsService {
};
}
}
private applyRestrictedFieldsToQueryBuilder<T extends ObjectLiteral>(
queryBuilder: WorkspaceSelectQueryBuilder<T>,
repository: WorkspaceRepository<T>,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): WorkspaceSelectQueryBuilder<T> {
const restrictedFields =
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
if (!restrictedFields || isEmpty(restrictedFields)) {
return queryBuilder;
}
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata: {
objectMetadataMapItem: flatObjectMetadata,
flatFieldMetadataMaps,
},
});
return queryBuilder.setFindOptions({
// @ts-expect-error - TypeORM typing limitation with dynamic select fields
select: selectableFields,
});
}
private async getObjectRecords<T extends ObjectLiteral>({
objectName,
filter,
orderBy,
limit,
offset,
repository,
graphqlQueryParser,
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
objectName: string;
filter:
| Partial<ObjectRecordFilter>
| Partial<ObjectRecordFilter>[]
| undefined;
orderBy: Partial<ObjectRecordOrderBy> | undefined;
limit: number | undefined;
offset: number;
repository: WorkspaceRepository<T>;
graphqlQueryParser: GraphqlQueryParser;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<T[]> {
const queryBuilder = repository.createQueryBuilder(objectName);
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
objectName,
filter ?? {},
);
const orderByWithIdCondition: ObjectRecordOrderBy = [
...(orderBy ?? []).filter((item) => item !== undefined),
{ id: OrderByDirection.AscNullsFirst },
];
const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
withFilterQueryBuilder,
orderByWithIdCondition,
objectName,
true,
);
const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder(
withOrderByQueryBuilder,
repository,
flatObjectMetadata,
flatFieldMetadataMaps,
);
return queryBuilderWithSelect
.skip(offset)
.take(limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS)
.getMany();
}
private async getTotalCount({
objectName,
filter,
repository,
graphqlQueryParser,
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
objectName: string;
filter:
| Partial<ObjectRecordFilter>
| Partial<ObjectRecordFilter>[]
| undefined;
repository: WorkspaceRepository<ObjectLiteral>;
graphqlQueryParser: GraphqlQueryParser;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<number> {
const countQueryBuilder = repository.createQueryBuilder(objectName);
const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
countQueryBuilder,
objectName,
filter ?? {},
);
const withDeletedCountQueryBuilder =
graphqlQueryParser.applyDeletedAtToBuilder(
withFilterCountQueryBuilder,
filter ?? {},
);
const queryBuilderWithSelect = this.applyRestrictedFieldsToQueryBuilder(
withDeletedCountQueryBuilder,
repository,
flatObjectMetadata,
flatFieldMetadataMaps,
);
return queryBuilderWithSelect.getCount();
}
}
@@ -1,28 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
import deepEqual from 'deep-equal';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/types/update-record-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Injectable()
export class UpdateRecordService {
private readonly logger = new Logger(UpdateRecordService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly commonUpdateOneRunner: CommonUpdateOneQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: UpdateRecordParams): Promise<ToolOutput> {
@@ -32,12 +30,8 @@ export class UpdateRecordService {
objectRecord,
fieldsToUpdate,
authContext,
rolePermissionConfig,
// updatedBy,
} = params;
const workspaceId = authContext.workspace.id;
if (!isDefined(objectRecordId) || !isValidUuid(objectRecordId)) {
return {
success: false,
@@ -47,144 +41,81 @@ export class UpdateRecordService {
}
try {
return await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectName,
rolePermissionConfig,
);
objectName,
});
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const objectId = objectIdByNameSingular[objectName];
const fieldsToUpdateArray = fieldsToUpdate ?? Object.keys(objectRecord);
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
if (fieldsToUpdateArray.length === 0) {
return {
success: true,
message: 'No fields to update',
result: undefined,
};
}
// Filter objectRecord to only include fieldsToUpdate
const filteredObjectRecord = Object.keys(objectRecord).reduce(
(acc, key) => {
if (fieldsToUpdateArray.includes(key)) {
return { ...acc, [key]: objectRecord[key] };
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
return acc;
},
{},
);
const restrictedFields =
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
// Clean undefined values from the record data (including nested composite fields)
// This prevents validation errors for partial composite field inputs
const cleanedRecord = removeUndefinedFromRecord(filteredObjectRecord);
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const updatedRecord = await this.commonUpdateOneRunner.execute(
{
id: objectRecordId,
data: cleanedRecord,
selectedFields,
},
queryRunnerContext,
);
const previousObjectRecord = await repository.findOne({
where: {
id: objectRecordId,
},
select: selectedColumns,
});
this.logger.log(`Record updated successfully in ${objectName}`);
if (!previousObjectRecord) {
throw new RecordCrudException(
`Failed to update: Record ${objectName} with id ${objectRecordId} not found`,
RecordCrudExceptionCode.RECORD_NOT_FOUND,
);
}
const fieldsToUpdateArray =
fieldsToUpdate || Object.keys(objectRecord);
if (fieldsToUpdateArray.length === 0) {
return {
success: true,
message: 'No fields to update',
result: previousObjectRecord,
};
}
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const objectRecordWithFilteredFields = Object.keys(
objectRecord,
).reduce((acc, key) => {
if (fieldsToUpdateArray.includes(key)) {
return {
...acc,
[key]: objectRecord[key],
};
}
return acc;
}, {});
const transformedObjectRecord =
await this.recordInputTransformerService.process({
recordInput: objectRecordWithFilteredFields,
return {
success: true,
message: `Record updated successfully in ${objectName}`,
result: updatedRecord,
recordReferences: [
{
objectNameSingular: objectName,
recordId: objectRecordId,
displayName: getRecordDisplayName(
updatedRecord,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const updatedObjectRecord = {
...previousObjectRecord,
...objectRecordWithFilteredFields,
};
if (!deepEqual(updatedObjectRecord, previousObjectRecord)) {
await repository.update(
objectRecordId,
{
...transformedObjectRecord,
/* updatedBy: updatedBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
workspaceMemberId: null,
context: {},
},*/
},
undefined,
selectedColumns,
);
}
this.logger.log(`Record updated successfully in ${objectName}`);
return {
success: true,
message: `Record updated successfully in ${objectName}`,
result: updatedObjectRecord,
recordReferences: [
{
objectNameSingular: objectName,
recordId: objectRecordId,
displayName: getRecordDisplayName(
updatedObjectRecord,
flatObjectMetadata,
flatFieldMetadataMaps,
),
},
],
};
},
);
),
},
],
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
@@ -1,197 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type UpsertRecordParams } from 'src/engine/core-modules/record-crud/types/upsert-record-params.type';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { getCompositeTypeOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/get-composite-type-or-throw.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Injectable()
export class UpsertRecordService {
private readonly logger = new Logger(UpsertRecordService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly commonCreateOneRunner: CommonCreateOneQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: UpsertRecordParams): Promise<ToolOutput> {
const { objectName, objectRecord, authContext, rolePermissionConfig } =
params;
const workspaceId = authContext.workspace.id;
const { objectName, objectRecord, authContext } = params;
try {
return await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectName,
rolePermissionConfig,
);
const { queryRunnerContext, selectedFields, flatObjectMetadata } =
await this.commonApiContextBuilder.build({
authContext,
objectName,
});
const fieldsToUpdateArray = Object.keys(objectRecord).filter(
(field) => isDefined(objectRecord[field]),
);
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = repository.internalContext;
// Clean undefined values from the record data (including nested composite fields)
// This prevents validation errors for partial composite field inputs
const cleanedRecord = removeUndefinedFromRecord(objectRecord);
const objectId = objectIdByNameSingular[objectName];
if (!isDefined(objectId)) {
throw new RecordCrudException(
`Object ${objectName} not found`,
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: objectId,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const objectRecordWithFilteredFields = Object.keys(
objectRecord,
).reduce((acc, key) => {
if (fieldsToUpdateArray.includes(key)) {
return {
...acc,
[key]: objectRecord[key],
};
}
return acc;
}, {});
const transformedObjectRecord =
await this.recordInputTransformerService.process({
recordInput: objectRecordWithFilteredFields,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const uniqueFieldsToUpdate = fieldsToUpdateArray
.map(
(fieldName) =>
fieldIdByName[fieldName] || fieldIdByJoinColumnName[fieldName],
)
.map((fieldId) => flatFieldMetadataMaps.byId[fieldId])
.filter(isDefined)
.filter((field) => field.isUnique || field.name === 'id');
const conflictPathsUniqueFieldsToUpdate =
uniqueFieldsToUpdate.flatMap((field) => {
if (isCompositeFieldMetadataType(field.type)) {
const compositeType = getCompositeTypeOrThrow(field.type);
const uniqueProperties = compositeType.properties.filter(
(prop) => prop.isIncludedInUniqueConstraint,
);
const propertiesToUse =
uniqueProperties.length > 0
? uniqueProperties
: [compositeType.properties[0]];
return propertiesToUse.map((prop) =>
computeCompositeColumnName(field, prop),
);
}
return [field.name];
});
const conflictPaths =
conflictPathsUniqueFieldsToUpdate.length > 0
? conflictPathsUniqueFieldsToUpdate
: ['id'];
const restrictedFields =
repository.objectRecordsPermissions?.[flatObjectMetadata.id]
?.restrictedFields;
const selectedColumns = getSelectedColumnsFromRestrictedFields(
restrictedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const upsertResult = await repository.upsert(
transformedObjectRecord,
{
conflictPaths: conflictPaths,
},
undefined,
selectedColumns,
);
const upsertedRecordId = upsertResult.identifiers?.[0].id;
if (!isDefined(upsertedRecordId)) {
throw new RecordCrudException(
`Failed to upsert record in ${objectName}`,
RecordCrudExceptionCode.RECORD_UPSERT_FAILED,
);
}
const upsertedRecord = await repository.findOne({
where: {
id: upsertedRecordId,
},
select: selectedColumns,
});
if (!upsertedRecord) {
throw new RecordCrudException(
`Record not found after upsert with id ${upsertedRecordId} in ${objectName}`,
RecordCrudExceptionCode.RECORD_UPSERT_FAILED,
);
}
this.logger.log(`Record upserted successfully in ${objectName}`);
return {
success: true,
message: `Record upserted successfully in ${objectName}`,
result: upsertedRecord,
};
// Use Common API with upsert flag - it handles conflict detection automatically
const upsertedRecord = await this.commonCreateOneRunner.execute(
{
data: cleanedRecord,
selectedFields,
upsert: true,
},
queryRunnerContext,
);
this.logger.log(`Record upserted successfully in ${objectName}`);
return {
success: true,
message: `Record upserted successfully in ${objectName}`,
result: upsertedRecord,
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
@@ -1,29 +0,0 @@
import isEmpty from 'lodash.isempty';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const getSelectedColumnsFromRestrictedFields = (
restrictedFields: RestrictedFieldsPermissions | undefined,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): string[] | undefined => {
if (!restrictedFields || isEmpty(restrictedFields)) {
return undefined;
}
const selectableFields = getAllSelectableColumnNames({
restrictedFields,
objectMetadata: {
objectMetadataMapItem: flatObjectMetadata,
flatFieldMetadataMaps,
},
});
return Object.keys(selectableFields).filter(
(columnName) => selectableFields[columnName],
);
};
@@ -0,0 +1,34 @@
import { isDefined } from 'twenty-shared/utils';
// Recursively removes undefined values from an object
// This is needed because workflows/tools may pass partial composite fields
// with undefined sub-properties, but the validation layer expects either
// a value or null (not undefined)
export const removeUndefinedFromRecord = <T extends Record<string, unknown>>(
record: T,
): T => {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
if (!isDefined(value)) {
continue;
}
// Recursively clean nested objects (composite fields like LINKS, ADDRESS, etc.)
// but preserve arrays as-is (they should be handled separately if needed)
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
const cleaned = removeUndefinedFromRecord(
value as Record<string, unknown>,
);
// Only include the nested object if it has at least one defined property
if (Object.keys(cleaned).length > 0) {
result[key] = cleaned;
}
} else {
result[key] = value;
}
}
return result as T;
};
@@ -7,6 +7,7 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import {
type ToolProvider,
type ToolProviderContext,
@@ -45,6 +46,23 @@ export class DatabaseToolProvider implements ToolProvider {
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
// Build authContext from available context info
// userWorkspaceId is required for user-based tool generation
if (!context.userWorkspaceId) {
return tools;
}
const authContext: WorkspaceAuthContext = {
user: context.userId ? { id: context.userId } : null,
workspace: {
id: context.workspaceId,
} as WorkspaceAuthContext['workspace'],
workspaceMemberId: undefined,
userWorkspaceId: context.userWorkspaceId,
apiKey: null,
application: null,
} as WorkspaceAuthContext;
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'rolesPermissions',
@@ -108,6 +126,7 @@ export class DatabaseToolProvider implements ToolProvider {
},
{
workspaceId: context.workspaceId,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
actorContext: context.actorContext,
},
@@ -59,8 +59,15 @@ export class ToolRegistryService {
async buildToolIndex(
workspaceId: string,
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
): Promise<ToolIndexEntry[]> {
const context = this.buildContext(workspaceId, roleId);
const context = this.buildContext(
workspaceId,
roleId,
undefined,
options?.userId,
options?.userWorkspaceId,
);
const entries: ToolIndexEntry[] = [];
for (const provider of this.providers) {
@@ -82,10 +89,16 @@ export class ToolRegistryService {
query: string,
workspaceId: string,
roleId: string,
options: ToolSearchOptions = {},
options: ToolSearchOptions & {
userId?: string;
userWorkspaceId?: string;
} = {},
): Promise<ToolIndexEntry[]> {
const { limit = 5, category } = options;
const index = await this.buildToolIndex(workspaceId, roleId);
const { limit = 5, category, userId, userWorkspaceId } = options;
const index = await this.buildToolIndex(workspaceId, roleId, {
userId,
userWorkspaceId,
});
const queryLower = query.toLowerCase();
const queryTerms = queryLower
@@ -16,7 +16,7 @@ import {
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Relation,
type Relation,
UpdateDateColumn,
} from 'typeorm';
@@ -1,7 +1,7 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { Application } from 'cloudflare/resources/zero-trust/access/applications/applications';
import { type Application } from 'cloudflare/resources/zero-trust/access/applications/applications';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import {
Check,
@@ -14,7 +14,7 @@ import {
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Relation,
type Relation,
UpdateDateColumn,
} from 'typeorm';