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:
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+158
@@ -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] ?? {};
|
||||
}
|
||||
}
|
||||
+59
-125
@@ -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 {
|
||||
|
||||
+54
-90
@@ -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 {
|
||||
|
||||
+49
-226
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+68
-137
@@ -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 {
|
||||
|
||||
+40
-168
@@ -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 {
|
||||
|
||||
-29
@@ -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],
|
||||
);
|
||||
};
|
||||
+34
@@ -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;
|
||||
};
|
||||
+19
@@ -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,
|
||||
},
|
||||
|
||||
+17
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user