Emit proper event on createOrUpdate csv import operation (#12163)

- use proper event emitter when upserting records with csv import
- After:


https://github.com/user-attachments/assets/8303da38-2e35-4f4c-bb13-8a7a222971b7
This commit is contained in:
martmull
2025-05-21 11:59:50 +02:00
committed by GitHub
parent 819b3c6c0d
commit 8e2d0139ed
15 changed files with 216 additions and 124 deletions

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { In, InsertResult, ObjectLiteral } from 'typeorm';
import {
@ -21,6 +21,7 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@Injectable()
export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResolverService<
@ -30,7 +31,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<ObjectRecord[]> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { roleId } = executionArgs;
@ -44,12 +45,6 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
objectMetadataMaps,
);
this.apiEventEmitterService.emitCreateEvents(
upsertedRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
const shouldBypassPermissionChecks = executionArgs.isExecutedByApiKey;
await this.processNestedRelationsIfNeeded(
@ -102,18 +97,24 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
raw: [],
};
await this.processRecordsToUpdate(
recordsToUpdate,
executionArgs.repository,
await this.processRecordsToUpdate({
partialRecordsToUpdate: recordsToUpdate,
existingRecords,
repository: executionArgs.repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps: executionArgs.options.objectMetadataMaps,
result,
);
authContext: executionArgs.options.authContext,
});
await this.processRecordsToInsert(
await this.processRecordsToInsert({
recordsToInsert,
executionArgs.repository,
repository: executionArgs.repository,
result,
);
objectMetadataItemWithFieldMaps,
objectMetadataMaps: executionArgs.options.objectMetadataMaps,
authContext: executionArgs.options.authContext,
});
return result;
}
@ -259,47 +260,116 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
return { recordsToUpdate, recordsToInsert };
}
private async processRecordsToUpdate(
recordsToUpdate: Partial<ObjectRecord>[],
repository: WorkspaceRepository<ObjectLiteral>,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
result: InsertResult,
): Promise<void> {
for (const record of recordsToUpdate) {
const recordId = record.id as string;
private async processRecordsToUpdate({
partialRecordsToUpdate,
existingRecords,
repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
result,
authContext,
}: {
partialRecordsToUpdate: Partial<ObjectRecord>[];
existingRecords: Partial<ObjectRecord>[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
result: InsertResult;
authContext: AuthContext;
}): Promise<void> {
for (const partialRecordToUpdate of partialRecordsToUpdate) {
const recordId = partialRecordToUpdate.id as string;
// we should not update an existing record's createdBy value
const recordWithoutCreatedByUpdate = this.getRecordWithoutCreatedBy(
record,
objectMetadataItemWithFieldMaps,
);
const partialRecordToUpdateWithoutCreatedByUpdate =
this.getRecordWithoutCreatedBy(
partialRecordToUpdate,
objectMetadataItemWithFieldMaps,
);
const formattedRecord = formatData(
recordWithoutCreatedByUpdate,
const formattedPartialRecordToUpdate = formatData(
partialRecordToUpdateWithoutCreatedByUpdate,
objectMetadataItemWithFieldMaps,
);
// TODO: we should align update and insert
// For insert, formating is done in the server
// While for update, formatting is done at the resolver level
await repository.update(recordId, formattedRecord);
await repository.update(recordId, formattedPartialRecordToUpdate);
result.identifiers.push({ id: recordId });
result.generatedMaps.push({ id: recordId });
const [updatedRecord] = await repository.find({
where: { id: recordId },
});
if (!isDefined(updatedRecord)) {
continue;
}
const record = formatResult<ObjectRecord>(
updatedRecord,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
const existingRecord = formatResult<ObjectRecord>(
existingRecords.find((record) => record.id === recordId),
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
this.apiEventEmitterService.emitUpdateEvents({
existingRecords: [existingRecord],
records: [record],
updatedFields: Object.keys(formattedPartialRecordToUpdate),
authContext,
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
}
}
private async processRecordsToInsert(
recordsToInsert: Partial<ObjectRecord>[],
repository: WorkspaceRepository<ObjectLiteral>,
result: InsertResult,
): Promise<void> {
private async processRecordsToInsert({
recordsToInsert,
repository,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
result,
authContext,
}: {
recordsToInsert: Partial<ObjectRecord>[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
result: InsertResult;
authContext: AuthContext;
}): Promise<void> {
const formattedInsertedRecords: ObjectRecord[] = [];
if (recordsToInsert.length > 0) {
const insertResult = await repository.insert(recordsToInsert);
result.identifiers.push(...insertResult.identifiers);
result.generatedMaps.push(...insertResult.generatedMaps);
result.raw.push(...insertResult.raw);
formattedInsertedRecords.push(
...insertResult.raw.map((record: ObjectRecord) =>
formatResult<ObjectRecord>(
record,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
),
),
);
}
this.apiEventEmitterService.emitCreateEvents({
records: formattedInsertedRecords,
authContext,
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
}
private async fetchUpsertedRecords(

View File

@ -53,11 +53,11 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv
objectMetadataMaps,
);
this.apiEventEmitterService.emitCreateEvents(
upsertedRecords,
this.apiEventEmitterService.emitCreateEvents({
records: upsertedRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({

View File

@ -54,11 +54,11 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol
objectMetadataMaps,
);
this.apiEventEmitterService.emitDeletedEvents(
formattedDeletedRecords,
this.apiEventEmitterService.emitDeletedEvents({
records: formattedDeletedRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({

View File

@ -47,11 +47,11 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv
objectMetadataMaps,
);
this.apiEventEmitterService.emitDeletedEvents(
formattedDeletedRecords,
this.apiEventEmitterService.emitDeletedEvents({
records: formattedDeletedRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (formattedDeletedRecords.length === 0) {
throw new GraphqlQueryRunnerException(

View File

@ -52,11 +52,11 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso
objectMetadataMaps,
);
this.apiEventEmitterService.emitDestroyEvents(
deletedRecords,
this.apiEventEmitterService.emitDestroyEvents({
records: deletedRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({

View File

@ -52,11 +52,11 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol
objectMetadataMaps,
);
this.apiEventEmitterService.emitDestroyEvents(
deletedRecords,
this.apiEventEmitterService.emitDestroyEvents({
records: deletedRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({

View File

@ -54,11 +54,11 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso
objectMetadataMaps,
);
this.apiEventEmitterService.emitRestoreEvents(
formattedRestoredRecords,
this.apiEventEmitterService.emitRestoreEvents({
records: formattedRestoredRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({

View File

@ -47,11 +47,11 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol
objectMetadataMaps,
);
this.apiEventEmitterService.emitRestoreEvents(
formattedRestoredRecords,
this.apiEventEmitterService.emitRestoreEvents({
records: formattedRestoredRecords,
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (formattedRestoredRecords.length === 0) {
throw new GraphqlQueryRunnerException(

View File

@ -89,13 +89,13 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol
objectMetadataMaps,
);
this.apiEventEmitterService.emitUpdateEvents(
formattedExistingRecords,
formattedUpdatedRecords,
Object.keys(executionArgs.args.data),
this.apiEventEmitterService.emitUpdateEvents({
existingRecords: formattedExistingRecords,
records: formattedUpdatedRecords,
updatedFields: Object.keys(executionArgs.args.data),
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({

View File

@ -74,13 +74,13 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv
objectMetadataMaps,
);
this.apiEventEmitterService.emitUpdateEvents(
formattedExistingRecords,
formattedUpdatedRecords,
Object.keys(executionArgs.args.data),
this.apiEventEmitterService.emitUpdateEvents({
existingRecords: formattedExistingRecords,
records: formattedUpdatedRecords,
updatedFields: Object.keys(executionArgs.args.data),
authContext,
objectMetadataItemWithFieldMaps,
);
objectMetadataItem: objectMetadataItemWithFieldMaps,
});
if (formattedUpdatedRecords.length === 0) {
throw new GraphqlQueryRunnerException(

View File

@ -12,11 +12,15 @@ import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/worksp
export class ApiEventEmitterService {
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
public emitCreateEvents<T extends ObjectRecord>(
records: T[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
): void {
public emitCreateEvents<T extends ObjectRecord>({
records,
authContext,
objectMetadataItem,
}: {
records: T[];
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.CREATED,
@ -33,13 +37,19 @@ export class ApiEventEmitterService {
});
}
public emitUpdateEvents<T extends ObjectRecord>(
existingRecords: T[],
records: T[],
updatedFields: string[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
): void {
public emitUpdateEvents<T extends ObjectRecord>({
existingRecords,
records,
updatedFields,
authContext,
objectMetadataItem,
}: {
existingRecords: T[];
records: T[];
updatedFields: string[];
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
const mappedExistingRecords = existingRecords.reduce(
(acc, { id, ...record }) => ({
...acc,
@ -78,11 +88,15 @@ export class ApiEventEmitterService {
});
}
public emitDeletedEvents<T extends ObjectRecord>(
records: T[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
): void {
public emitDeletedEvents<T extends ObjectRecord>({
records,
authContext,
objectMetadataItem,
}: {
records: T[];
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.DELETED,
@ -101,11 +115,15 @@ export class ApiEventEmitterService {
});
}
public emitRestoreEvents<T extends ObjectRecord>(
records: T[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
): void {
public emitRestoreEvents<T extends ObjectRecord>({
records,
authContext,
objectMetadataItem,
}: {
records: T[];
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.RESTORED,
@ -124,11 +142,15 @@ export class ApiEventEmitterService {
});
}
public emitDestroyEvents<T extends ObjectRecord>(
records: T[],
authContext: AuthContext,
objectMetadataItem: ObjectMetadataInterface,
): void {
public emitDestroyEvents<T extends ObjectRecord>({
records,
authContext,
objectMetadataItem,
}: {
records: T[];
authContext: AuthContext;
objectMetadataItem: ObjectMetadataInterface;
}): void {
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
action: DatabaseEventAction.DESTROYED,

View File

@ -54,11 +54,11 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
const createdRecords = await repository.save(recordsToCreate);
this.apiEventEmitterService.emitCreateEvents(
createdRecords,
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
this.apiEventEmitterService.emitCreateEvents({
records: createdRecords,
authContext: this.getAuthContextFromRequest(request),
objectMetadataItem: objectMetadata.objectMetadataMapItem,
});
const records = await this.getRecord({
recordIds: createdRecords.map((record) => record.id),

View File

@ -37,11 +37,11 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
const createdRecord = await repository.save(recordToCreate);
this.apiEventEmitterService.emitCreateEvents(
[createdRecord],
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
this.apiEventEmitterService.emitCreateEvents({
records: [createdRecord],
authContext: this.getAuthContextFromRequest(request),
objectMetadataItem: objectMetadata.objectMetadataMapItem,
});
const records = await this.getRecord({
recordIds: [createdRecord.id],

View File

@ -23,11 +23,11 @@ export class RestApiDeleteOneHandler extends RestApiBaseHandler {
await repository.delete(recordId);
this.apiEventEmitterService.emitDestroyEvents(
[recordToDelete],
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
this.apiEventEmitterService.emitDestroyEvents({
records: [recordToDelete],
authContext: this.getAuthContextFromRequest(request),
objectMetadataItem: objectMetadata.objectMetadataMapItem,
});
return this.formatResult({
operation: 'delete',

View File

@ -33,13 +33,13 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
...overriddenBody,
});
this.apiEventEmitterService.emitUpdateEvents(
[recordToUpdate],
[updatedRecord],
Object.keys(request.body),
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
this.apiEventEmitterService.emitUpdateEvents({
existingRecords: [recordToUpdate],
records: [updatedRecord],
updatedFields: Object.keys(request.body),
authContext: this.getAuthContextFromRequest(request),
objectMetadataItem: objectMetadata.objectMetadataMapItem,
});
const records = await this.getRecord({
recordIds: [updatedRecord.id],