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:
@ -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>
|
||||
|
||||
@ -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>>;
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,8 +7,3 @@ export class ObjectRecordBaseEvent {
|
||||
objectMetadata: ObjectMetadataInterface;
|
||||
properties: any;
|
||||
}
|
||||
|
||||
export class ObjectRecordBaseEventWithNameAndWorkspaceId extends ObjectRecordBaseEvent {
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}'`);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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}**, ***.**${item.nameSingular}**, ***.*****`,
|
||||
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 }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user