6071 return only updated fields of records in zapier update trigger (#8193)

- move webhook triggers into `entity-events-to-db.listener.ts`
- refactor event management
- add a `@OnDatabaseEvent` decorator to manage database events
- add updatedFields in updated events
- update openApi webhooks docs
- update zapier integration
This commit is contained in:
martmull
2024-11-04 17:44:36 +01:00
committed by GitHub
parent 741020fbb0
commit 695991881f
62 changed files with 547 additions and 578 deletions

View File

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
UpdateSubscriptionJob,
@ -12,6 +11,8 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/workspace-event.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { OnDatabaseEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class BillingWorkspaceMemberListener {
@ -21,8 +22,8 @@ export class BillingWorkspaceMemberListener {
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('workspaceMember.created')
@OnEvent('workspaceMember.deleted')
@OnDatabaseEvent('workspaceMember', DatabaseEventAction.CREATED)
@OnDatabaseEvent('workspaceMember', DatabaseEventAction.DELETED)
async handleCreateOrDeleteEvent(
payload: WorkspaceEventBatch<
ObjectRecordCreateEvent<WorkspaceMemberWorkspaceEntity>

View File

@ -1,10 +1,14 @@
import { ObjectRecordBaseEvent } from 'src/engine/core-modules/event-emitter/types/object-record.base.event';
type Diff<T> = {
[K in keyof T]: { before: T[K]; after: T[K] };
};
export class ObjectRecordUpdateEvent<T> extends ObjectRecordBaseEvent {
properties: {
updatedFields?: string[];
before: T;
after: T;
diff?: Partial<T>;
diff?: Partial<Diff<T>>;
};
}

View File

@ -7,8 +7,3 @@ export class ObjectRecordBaseEvent {
objectMetadata: ObjectMetadataInterface;
properties: any;
}
export class ObjectRecordBaseEventWithNameAndWorkspaceId extends ObjectRecordBaseEvent {
name: string;
workspaceId: string;
}

View File

@ -45,76 +45,76 @@ describe('objectRecordChangedValues', () => {
name: { before: 'Original Name', after: 'Updated Name' },
});
});
});
it('ignores changes to the updatedAt field', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516d',
updatedAt: new Date('2020-01-01').toDateString(),
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516d',
updatedAt: new Date('2024-01-01').toDateString(),
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
[],
mockObjectMetadata,
);
expect(result).toEqual({});
});
it('returns an empty object when there are no changes', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516k',
name: 'Name',
value: 100,
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516k',
name: 'Name',
value: 100,
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name', 'value'],
mockObjectMetadata,
);
expect(result).toEqual({});
});
it('correctly handles a mix of changed, unchanged, and special case values', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516l',
name: 'Original',
status: 'active',
updatedAt: new Date(2020, 1, 1).toDateString(),
config: { theme: 'dark' },
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516l',
name: 'Updated',
status: 'active',
updatedAt: new Date(2021, 1, 1).toDateString(),
config: { theme: 'light' },
};
const expectedChanges = {
name: { before: 'Original', after: 'Updated' },
config: { before: { theme: 'dark' }, after: { theme: 'light' } },
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name', 'config', 'status'],
mockObjectMetadata,
);
expect(result).toEqual(expectedChanges);
it('ignores changes to the updatedAt field', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516d',
updatedAt: new Date('2020-01-01').toDateString(),
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516d',
updatedAt: new Date('2024-01-01').toDateString(),
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
[],
mockObjectMetadata,
);
expect(result).toEqual({});
});
it('returns an empty object when there are no changes', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516k',
name: 'Name',
value: 100,
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516k',
name: 'Name',
value: 100,
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name', 'value'],
mockObjectMetadata,
);
expect(result).toEqual({});
});
it('correctly handles a mix of changed, unchanged, and special case values', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516l',
name: 'Original',
status: 'active',
updatedAt: new Date(2020, 1, 1).toDateString(),
config: { theme: 'dark' },
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516l',
name: 'Updated',
status: 'active',
updatedAt: new Date(2021, 1, 1).toDateString(),
config: { theme: 'light' },
};
const expectedChanges = {
name: { before: 'Original', after: 'Updated' },
config: { before: { theme: 'dark' }, after: { theme: 'light' } },
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name', 'config', 'status'],
mockObjectMetadata,
);
expect(result).toEqual(expectedChanges);
});
});

View File

@ -1,85 +0,0 @@
import { v4 } from 'uuid';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
export const generateFakeObjectRecordEvent = <Entity>(
objectMetadataEntity: ObjectMetadataEntity,
action: 'created' | 'updated' | 'deleted' | 'destroyed',
):
| ObjectRecordCreateEvent<Entity>
| ObjectRecordUpdateEvent<Entity>
| ObjectRecordDeleteEvent<Entity>
| ObjectRecordDestroyEvent<Entity> => {
const recordId = v4();
const userId = v4();
const workspaceMemberId = v4();
const after = objectMetadataEntity.fields.reduce((acc, field) => {
acc[field.name] = generateFakeValue(field.type);
return acc;
}, {} as Entity);
if (action === 'created') {
return {
recordId,
userId,
workspaceMemberId,
objectMetadata: objectMetadataEntity,
properties: {
after,
},
} satisfies ObjectRecordCreateEvent<Entity>;
}
const before = objectMetadataEntity.fields.reduce((acc, field) => {
acc[field.name] = generateFakeValue(field.type);
return acc;
}, {} as Entity);
if (action === 'updated') {
return {
recordId,
userId,
workspaceMemberId,
objectMetadata: objectMetadataEntity,
properties: {
before,
after,
diff: after,
},
} satisfies ObjectRecordUpdateEvent<Entity>;
}
if (action === 'deleted') {
return {
recordId,
userId,
workspaceMemberId,
objectMetadata: objectMetadataEntity,
properties: {
before,
},
} satisfies ObjectRecordDeleteEvent<Entity>;
}
if (action === 'destroyed') {
return {
recordId,
userId,
workspaceMemberId,
objectMetadata: objectMetadataEntity,
properties: {
before,
},
} satisfies ObjectRecordDestroyEvent<Entity>;
}
throw new Error(`Unknown action '${action}'`);
};

View File

@ -15,7 +15,7 @@ export const objectRecordChangedValues = (
objectMetadata.fields.map((field) => [field.name, field]),
);
const changedValues = Object.keys(newRecord).reduce(
return Object.keys(newRecord).reduce(
(acc, key) => {
const field = fieldsByKey.get(key);
const oldRecordValue = oldRecord[key];
@ -36,6 +36,4 @@ export const objectRecordChangedValues = (
},
{} as Record<string, { before: any; after: any }>,
);
return changedValues;
};

View File

@ -27,6 +27,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module';
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module';
@Module({
imports: [
@ -49,6 +50,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
WorkspaceQueryRunnerJobModule,
AutoCompaniesAndContactsCreationJobModule,
TimelineJobModule,
WebhookJobModule,
WorkflowModule,
],
providers: [

View File

@ -37,6 +37,7 @@ import {
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { capitalize } from 'src/utils/capitalize';
import { getServerUrl } from 'src/utils/get-server-url';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class OpenApiService {
@ -81,9 +82,18 @@ export class OpenApiService {
schema.webhooks = objectMetadataItems.reduce(
(paths, item) => {
paths[`Create ${item.nameSingular}`] = computeWebhooks('create', item);
paths[`Update ${item.nameSingular}`] = computeWebhooks('update', item);
paths[`Delete ${item.nameSingular}`] = computeWebhooks('delete', item);
paths[`Create ${item.nameSingular}`] = computeWebhooks(
DatabaseEventAction.CREATED,
item,
);
paths[`Update ${item.nameSingular}`] = computeWebhooks(
DatabaseEventAction.UPDATED,
item,
);
paths[`Delete ${item.nameSingular}`] = computeWebhooks(
DatabaseEventAction.DELETED,
item,
);
return paths;
},

View File

@ -2,17 +2,24 @@ import { OpenAPIV3_1 } from 'openapi-types';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { capitalize } from 'src/utils/capitalize';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
export const computeWebhooks = (
type: 'create' | 'update' | 'delete',
type: DatabaseEventAction,
item: ObjectMetadataEntity,
): OpenAPIV3_1.PathItemObject => {
const updatedFields = {
type: 'array',
items: {
type: 'string',
},
};
return {
post: {
tags: [item.nameSingular],
security: [],
requestBody: {
description: `*${type}*.**${item.nameSingular}**, *&#42;*.**${item.nameSingular}**, *&#42;*.**&#42;**`,
content: {
'application/json': {
schema: {
@ -22,17 +29,9 @@ export const computeWebhooks = (
type: 'string',
example: 'https://example.com/incomingWebhook',
},
description: {
eventName: {
type: 'string',
example: 'A sample description',
},
eventType: {
type: 'string',
enum: [
'*.*',
'*.' + item.nameSingular,
type + '.' + item.nameSingular,
],
example: `${item.nameSingular}.${type}`,
},
objectMetadata: {
type: 'object',
@ -60,8 +59,9 @@ export const computeWebhooks = (
example: '2024-02-14T11:27:01.779Z',
},
record: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
$ref: `#/components/schemas/${capitalize(item.nameSingular)} for Response`,
},
...(type === DatabaseEventAction.UPDATED && { updatedFields }),
},
},
},

View File

@ -18,6 +18,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(
@ -88,7 +89,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
payload.recordId = workspaceMember[0].id;
this.workspaceEventEmitter.emit(
'workspaceMember.created',
`workspaceMember.${DatabaseEventAction.CREATED}`,
[payload],
workspaceId,
);

View File

@ -17,6 +17,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class UserService extends TypeOrmQueryService<User> {
@ -115,7 +116,7 @@ export class UserService extends TypeOrmQueryService<User> {
payload.recordId = workspaceMember.id;
this.workspaceEventEmitter.emit(
'workspaceMember.deleted',
`workspaceMember.${DatabaseEventAction.DELETED}`,
[payload],
workspaceId,
);

View File

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import {
@ -13,6 +12,8 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/workspace-event.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { OnDatabaseEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class WorkspaceWorkspaceMemberListener {
@ -22,7 +23,7 @@ export class WorkspaceWorkspaceMemberListener {
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('workspaceMember.updated')
@OnDatabaseEvent('workspaceMember', DatabaseEventAction.UPDATED)
async handleUpdateEvent(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
@ -50,7 +51,7 @@ export class WorkspaceWorkspaceMemberListener {
);
}
@OnEvent('workspaceMember.deleted')
@OnDatabaseEvent('workspaceMember', DatabaseEventAction.DELETED)
async handleDeleteEvent(
payload: WorkspaceEventBatch<
ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>