Introduce webhook v2 (#17456)

Migrate webhook to v2 entity
This commit is contained in:
Charles Bochet
2026-01-27 16:32:33 +01:00
committed by GitHub
parent 27e5b9797b
commit bc7791871f
83 changed files with 1574 additions and 975 deletions
@@ -53,7 +53,7 @@ import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.mod
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
import { WebhookModule } from 'src/engine/metadata-modules/webhook/webhook.module';
import { WorkflowApiModule } from 'src/engine/core-modules/workflow/workflow-api.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@@ -15,7 +15,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user
import { UpdateWorkspaceMemberEmailJob } from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WebhookJobModule } from 'src/engine/core-modules/webhook/jobs/webhook-job.module';
import { WebhookJobModule } from 'src/engine/metadata-modules/webhook/jobs/webhook-job.module';
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@@ -1,87 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { CreateWebhookInput } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
import { UpdateWebhookInput } from 'src/engine/core-modules/webhook/dtos/update-webhook.dto';
import { type WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
/**
* rest/webhooks is deprecated, use rest/metadata/webhooks instead
* rest/webhooks will be removed in the future
*/
@Controller(['rest/webhooks', 'rest/metadata/webhooks'])
@UseGuards(
JwtAuthGuard,
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@UseFilters(RestApiExceptionFilter)
export class WebhookController {
constructor(private readonly webhookService: WebhookService) {}
@Get()
async findAll(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookEntity[]> {
return this.webhookService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookEntity | null> {
return this.webhookService.findById(id, workspace.id);
}
@Post()
async create(
@Body() createWebhookDto: CreateWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookEntity> {
return this.webhookService.create({
targetUrl: createWebhookDto.targetUrl,
operations: createWebhookDto.operations || ['*.*'],
description: createWebhookDto.description,
secret: createWebhookDto.secret,
workspaceId: workspace.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() updateWebhookDto: UpdateWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookEntity | null> {
return this.webhookService.update(id, workspace.id, updateWebhookDto);
}
@Delete(':id')
async remove(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const result = await this.webhookService.delete(id, workspace.id);
return result !== null;
}
}
@@ -1,20 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUrl } from 'class-validator';
@InputType()
export class CreateWebhookInput {
@Field()
@IsNotEmpty()
@IsUrl()
targetUrl: string;
@Field(() => [String])
operations: string[];
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
secret?: string;
}
@@ -1,13 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class DeleteWebhookInput {
@Field(() => UUIDScalarType)
@IsNotEmpty()
@IsUUID()
id: string;
}
@@ -1,13 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class GetWebhookInput {
@Field(() => UUIDScalarType)
@IsNotEmpty()
@IsUUID()
id: string;
}
@@ -1,25 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateWebhookInput {
@Field(() => UUIDScalarType)
@IsNotEmpty()
@IsUUID()
id: string;
@Field({ nullable: true })
targetUrl?: string;
@Field(() => [String], { nullable: true })
operations?: string[];
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
secret?: string;
}
@@ -1,67 +0,0 @@
import { Logger } from '@nestjs/common';
import chunk from 'lodash.chunk';
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
CallWebhookJob,
type CallWebhookJobData,
} from 'src/engine/core-modules/webhook/jobs/call-webhook.job';
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { transformEventBatchToWebhookEvents } from 'src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events';
const WEBHOOK_JOBS_CHUNK_SIZE = 20;
@Processor(MessageQueue.webhookQueue)
export class CallWebhookJobsJob {
private readonly logger = new Logger(CallWebhookJobsJob.name);
constructor(
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
private readonly webhookService: WebhookService,
) {}
@Process(CallWebhookJobsJob.name)
async handle(
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
): Promise<void> {
// If you change that function, double check it does not break Zapier
// trigger in packages/twenty-zapier/src/triggers/trigger_record.ts
// Also change the openApi schema for webhooks
// packages/twenty-server/src/engine/core-modules/open-api/utils/computeWebhooks.utils.ts
const [nameSingular, operation] = workspaceEventBatch.name.split('.');
const webhooks = await this.webhookService.findByOperations(
workspaceEventBatch.workspaceId,
[
`${nameSingular}.${operation}`,
`*.${operation}`,
`${nameSingular}.*`,
'*.*',
],
);
const webhookEvents = transformEventBatchToWebhookEvents({
workspaceEventBatch,
webhooks,
});
const webhookEventsChunks = chunk(webhookEvents, WEBHOOK_JOBS_CHUNK_SIZE);
for (const webhookEventsChunk of webhookEventsChunks) {
await this.messageQueueService.add<CallWebhookJobData[]>(
CallWebhookJob.name,
webhookEventsChunk,
{ retryLimit: 3 },
);
}
}
}
@@ -1,125 +0,0 @@
import crypto from 'crypto';
import { getAbsoluteUrl } from 'twenty-shared/utils';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { WEBHOOK_RESPONSE_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service';
export type CallWebhookJobData = {
targetUrl: string;
eventName: string;
objectMetadata: { id: string; nameSingular: string };
workspaceId: string;
webhookId: string;
eventDate: Date;
userId?: string;
workspaceMemberId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
record: any;
updatedFields?: string[];
secret?: string;
};
@Processor(MessageQueue.webhookQueue)
export class CallWebhookJob {
constructor(
private readonly auditService: AuditService,
private readonly metricsService: MetricsService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private generateSignature(
payload: CallWebhookJobData,
secret: string,
timestamp: string,
): string {
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}:${JSON.stringify(payload)}`)
.digest('hex');
}
@Process(CallWebhookJob.name)
async handle(webhookJobEvents: CallWebhookJobData[]): Promise<void> {
await Promise.all(
webhookJobEvents.map(
async (webhookJobEvent) => await this.callWebhook(webhookJobEvent),
),
);
}
private async callWebhook(data: CallWebhookJobData): Promise<void> {
const commonPayload = {
url: data.targetUrl,
webhookId: data.webhookId,
eventName: data.eventName,
};
const auditService = this.auditService.createContext({
workspaceId: data.workspaceId,
});
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const { secret, ...payloadWithoutSecret } = data;
if (secret) {
headers['X-Twenty-Webhook-Timestamp'] = Date.now().toString();
headers['X-Twenty-Webhook-Signature'] = this.generateSignature(
payloadWithoutSecret,
secret,
headers['X-Twenty-Webhook-Timestamp'],
);
headers['X-Twenty-Webhook-Nonce'] = crypto
.randomBytes(16)
.toString('hex');
}
const axiosClient = this.secureHttpClientService.getHttpClient();
const response = await axiosClient.post(
getAbsoluteUrl(data.targetUrl),
payloadWithoutSecret,
{
headers,
timeout: 5_000,
},
);
const success = response.status >= 200 && response.status < 300;
auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
status: response.status,
success,
...commonPayload,
});
this.metricsService.incrementCounter({
key: MetricsKeys.JobWebhookCallCompleted,
shouldStoreInCache: false,
});
} catch (err) {
const isSSRFBlocked =
err instanceof Error &&
err.message.includes('internal IP address') &&
err.message.includes('is not allowed');
auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
success: false,
...commonPayload,
...(err.response && { status: err.response.status }),
...(isSSRFBlocked && {
error: 'Webhook URL resolves to a private/internal IP address',
}),
});
}
}
}
@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { CallWebhookJobsJob } from 'src/engine/core-modules/webhook/jobs/call-webhook-jobs.job';
import { CallWebhookJob } from 'src/engine/core-modules/webhook/jobs/call-webhook.job';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
@Module({
imports: [AuditModule, WebhookModule, MetricsModule, ToolModule],
providers: [CallWebhookJobsJob, CallWebhookJob],
})
export class WebhookJobModule {}
@@ -1,9 +0,0 @@
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
export type WorkspaceEventBatchForWebhook<WorkspaceEvent> = Omit<
WorkspaceEventBatch<WorkspaceEvent>,
'objectMetadata'
> & {
objectMetadata: Pick<ObjectMetadataEntity, 'id' | 'nameSingular'>;
};
@@ -1,249 +0,0 @@
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import type { WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { transformEventBatchToWebhookEvents } from 'src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
const mockObjectMetadata: FlatObjectMetadata = {
id: 'id',
nameSingular: 'nameSingular',
namePlural: 'namePlural',
workspaceId: 'workspaceId',
labelSingular: 'Label Singular',
labelPlural: 'Label Plural',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
createdAt: new Date(),
updatedAt: new Date(),
universalIdentifier: 'id',
fieldIds: [],
indexMetadataIds: [],
viewIds: [],
applicationId: null,
} as unknown as FlatObjectMetadata;
describe('transformEventBatchToWebhookEvents', () => {
it('should transform properly', () => {
const workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent> = {
workspaceId: 'workspaceId',
objectMetadata: mockObjectMetadata,
name: 'objectNameSingular.created',
events: [
{
recordId: 'recordId-1',
properties: {
after: {
id: 'id-1',
nameSingular: 'nameSingular-1',
},
},
},
{
recordId: 'recordId-2',
properties: {
before: {
id: 'id-2',
nameSingular: 'nameSingular-2',
},
},
},
{
recordId: 'recordId-3',
properties: {
after: {
id: 'id-3',
nameSingular: 'nameSingular-3',
secret: 'secret-3',
},
updatedFields: ['nameSingular'],
},
},
],
};
const webhooks = [
{
id: 'webhook-id',
targetUrl: 'targetUrl',
secret: 'secret',
},
{
id: 'webhook-id-2',
targetUrl: 'targetUrl-2',
secret: 'secret-2',
},
] as WebhookEntity[];
const result = transformEventBatchToWebhookEvents({
workspaceEventBatch,
webhooks,
});
const expectedResultWithoutEventDate = [
{
targetUrl: 'targetUrl',
eventName: 'objectNameSingular.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id',
record: {
id: 'id-1',
nameSingular: 'nameSingular-1',
},
secret: 'secret',
},
{
targetUrl: 'targetUrl',
eventName: 'objectNameSingular.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id',
record: {
id: 'id-2',
nameSingular: 'nameSingular-2',
},
secret: 'secret',
},
{
targetUrl: 'targetUrl',
eventName: 'objectNameSingular.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id',
record: {
id: 'id-3',
nameSingular: 'nameSingular-3',
secret: 'secret-3',
},
updatedFields: ['nameSingular'],
secret: 'secret',
},
{
targetUrl: 'targetUrl-2',
eventName: 'objectNameSingular.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id-2',
record: {
id: 'id-1',
nameSingular: 'nameSingular-1',
},
secret: 'secret-2',
},
{
targetUrl: 'targetUrl-2',
eventName: 'objectNameSingular.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id-2',
record: {
id: 'id-2',
nameSingular: 'nameSingular-2',
},
secret: 'secret-2',
},
{
targetUrl: 'targetUrl-2',
eventName: 'objectNameSingular.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id-2',
record: {
id: 'id-3',
nameSingular: 'nameSingular-3',
secret: 'secret-3',
},
updatedFields: ['nameSingular'],
secret: 'secret-2',
},
];
const resultWithoutEventDate = result.map((event) => {
const { eventDate: _, ...eventWithoutEventDate } = event;
return eventWithoutEventDate;
});
expect(resultWithoutEventDate).toEqual(expectedResultWithoutEventDate);
});
it('should sanitize records properly', () => {
const workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent> = {
workspaceId: 'workspaceId',
objectMetadata: mockObjectMetadata,
name: 'webhook.created',
events: [
{
recordId: 'recordId-1',
properties: {
after: {
id: 'id-1',
targetUrl: 'targetUrl-1',
secret: 'secret-1',
},
},
},
],
};
const webhooks = [
{
id: 'webhook-id',
targetUrl: 'targetUrl',
secret: 'secret',
},
] as WebhookEntity[];
const result = transformEventBatchToWebhookEvents({
workspaceEventBatch,
webhooks,
});
const expectedResultWithoutEventDate = [
{
targetUrl: 'targetUrl',
eventName: 'webhook.created',
objectMetadata: {
id: mockObjectMetadata.id,
nameSingular: mockObjectMetadata.nameSingular,
},
workspaceId: 'workspaceId',
webhookId: 'webhook-id',
record: {
id: 'id-1',
targetUrl: 'targetUrl-1',
// No secret
},
secret: 'secret',
},
];
const resultWithoutEventDate = result.map((event) => {
const { eventDate: _, ...eventWithoutEventDate } = event;
return eventWithoutEventDate;
});
expect(resultWithoutEventDate).toEqual(expectedResultWithoutEventDate);
});
});
@@ -1,35 +0,0 @@
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { transformEventToWebhookEvent } from 'src/engine/core-modules/webhook/utils/transform-event-to-webhook-event';
describe('transformEventToWebhookEvent', () => {
it('should transform event to webhook event', () => {
const record = {
recordId: 'recordId',
properties: {
after: {
id: 'id',
nameSingular: 'nameSingular',
secret: 'secret',
},
updatedFields: ['nameSingular'],
},
} as ObjectRecordEvent;
const expectedResult = {
record: {
id: 'id',
nameSingular: 'nameSingular',
secret: 'secret',
},
updatedFields: ['nameSingular'],
};
expect(
transformEventToWebhookEvent({
eventName: 'nameSingular.created',
event: record,
}),
).toEqual(expectedResult);
});
});
@@ -1,52 +0,0 @@
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type CallWebhookJobData } from 'src/engine/core-modules/webhook/jobs/call-webhook.job';
import { type WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { transformEventToWebhookEvent } from 'src/engine/core-modules/webhook/utils/transform-event-to-webhook-event';
export const transformEventBatchToWebhookEvents = ({
workspaceEventBatch,
webhooks,
}: {
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
webhooks: WebhookEntity[];
}): CallWebhookJobData[] => {
const result: CallWebhookJobData[] = [];
for (const webhook of webhooks) {
const targetUrl = webhook.targetUrl;
const eventName = workspaceEventBatch.name;
const objectMetadataForWebhook = {
id: workspaceEventBatch.objectMetadata.id,
nameSingular: workspaceEventBatch.objectMetadata.nameSingular,
};
const workspaceId = workspaceEventBatch.workspaceId;
const webhookId = webhook.id;
const eventDate = new Date();
const secret = webhook.secret;
for (const eventData of workspaceEventBatch.events) {
const { record, updatedFields } = transformEventToWebhookEvent({
eventName: workspaceEventBatch.name,
event: eventData,
});
result.push({
targetUrl,
eventName,
objectMetadata: objectMetadataForWebhook,
workspaceId,
webhookId,
eventDate,
userId: eventData.userId,
workspaceMemberId: eventData.workspaceMemberId,
record,
...(updatedFields && { updatedFields }),
secret,
});
}
}
return result;
};
@@ -1,35 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';
export const transformEventToWebhookEvent = ({
eventName,
event,
}: {
eventName: string;
event: ObjectRecordEvent;
}) => {
const [nameSingular, _] = eventName.split('.');
const record =
'after' in event.properties && isDefined(event.properties.after)
? event.properties.after
: 'before' in event.properties && isDefined(event.properties.before)
? event.properties.before
: {};
const updatedFields =
'updatedFields' in event.properties
? event.properties.updatedFields
: undefined;
const isWebhookEvent = nameSingular === 'webhook';
const sanitizedRecord = removeSecretFromWebhookRecord(record, isWebhookEvent);
return {
record: sanitizedRecord,
...(updatedFields && { updatedFields }),
};
};
@@ -1,28 +0,0 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
WebhookException,
WebhookExceptionCode,
} from 'src/engine/core-modules/webhook/webhook.exception';
export const webhookGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WebhookException) {
switch (error.code) {
case WebhookExceptionCode.WEBHOOK_NOT_FOUND:
throw new NotFoundError(error.message);
case WebhookExceptionCode.INVALID_TARGET_URL:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -1,52 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Index('IDX_WEBHOOK_WORKSPACE_ID', ['workspaceId'])
@Entity({ name: 'webhook', schema: 'core' })
@ObjectType('Webhook')
export class WebhookEntity extends WorkspaceRelatedEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
targetUrl: string;
@Field(() => [String])
@Column('text', { array: true, default: ['*.*'] })
operations: string[];
@Field({ nullable: true })
@Column({ nullable: true })
description?: string;
@Field()
@Column()
secret: string;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Field({ nullable: true })
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}
@@ -1,34 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum WebhookExceptionCode {
WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND',
INVALID_TARGET_URL = 'INVALID_TARGET_URL',
}
const getWebhookExceptionUserFriendlyMessage = (code: WebhookExceptionCode) => {
switch (code) {
case WebhookExceptionCode.WEBHOOK_NOT_FOUND:
return msg`Webhook not found.`;
case WebhookExceptionCode.INVALID_TARGET_URL:
return msg`Invalid target URL.`;
default:
assertUnreachable(code);
}
};
export class WebhookException extends CustomException<WebhookExceptionCode> {
constructor(
message: string,
code: WebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getWebhookExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,24 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { WebhookResolver } from 'src/engine/core-modules/webhook/webhook.resolver';
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WebhookController } from './controllers/webhook.controller';
@Module({
imports: [
TypeOrmModule.forFeature([WebhookEntity]),
AuthModule,
PermissionsModule,
WorkspaceCacheStorageModule,
],
providers: [WebhookService, WebhookResolver],
controllers: [WebhookController],
exports: [WebhookService, TypeOrmModule],
})
export class WebhookModule {}
@@ -1,97 +0,0 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { PermissionFlagType } from 'twenty-shared/constants';
import { CreateWebhookInput } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
import { DeleteWebhookInput } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto';
import { GetWebhookInput } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto';
import { UpdateWebhookInput } from 'src/engine/core-modules/webhook/dtos/update-webhook.dto';
import { webhookGraphqlApiExceptionHandler } from 'src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WebhookEntity } from './webhook.entity';
import { WebhookService } from './webhook.service';
@Resolver(() => WebhookEntity)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
export class WebhookResolver {
constructor(private readonly webhookService: WebhookService) {}
@Query(() => [WebhookEntity])
async webhooks(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookEntity[]> {
return this.webhookService.findByWorkspaceId(workspace.id);
}
@Query(() => WebhookEntity, { nullable: true })
async webhook(
@Args('input') input: GetWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookEntity | null> {
return this.webhookService.findById(input.id, workspace.id);
}
@Mutation(() => WebhookEntity)
async createWebhook(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('input') input: CreateWebhookInput,
): Promise<WebhookEntity> {
try {
return await this.webhookService.create({
targetUrl: input.targetUrl,
operations: input.operations,
description: input.description,
secret: input.secret,
workspaceId: workspace.id,
});
} catch (error) {
webhookGraphqlApiExceptionHandler(error);
throw error; // This line will never be reached but satisfies TypeScript
}
}
@Mutation(() => WebhookEntity, { nullable: true })
async updateWebhook(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('input') input: UpdateWebhookInput,
): Promise<WebhookEntity | null> {
try {
const updateData: QueryDeepPartialEntity<WebhookEntity> = {};
if (input.targetUrl !== undefined) updateData.targetUrl = input.targetUrl;
if (input.operations !== undefined)
updateData.operations = input.operations;
if (input.description !== undefined)
updateData.description = input.description;
if (input.secret !== undefined) updateData.secret = input.secret;
return await this.webhookService.update(
input.id,
workspace.id,
updateData,
);
} catch (error) {
webhookGraphqlApiExceptionHandler(error);
throw error; // This line will never be reached but satisfies TypeScript
}
}
@Mutation(() => Boolean)
async deleteWebhook(
@Args('input') input: DeleteWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const result = await this.webhookService.delete(input.id, workspace.id);
return result !== null;
}
}
@@ -1,420 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ArrayContains, IsNull } from 'typeorm';
import { WebhookEntity } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
import { WebhookService } from './webhook.service';
describe('WebhookService', () => {
let service: WebhookService;
let mockWebhookRepository: any;
const mockWorkspaceId = 'workspace-123';
const mockWebhookId = 'webhook-456';
const mockWebhook: WebhookEntity = {
id: mockWebhookId,
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
operations: ['create', 'update'],
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: undefined,
workspace: {} as any,
};
beforeEach(async () => {
mockWebhookRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookService,
{
provide: getRepositoryToken(WebhookEntity),
useValue: mockWebhookRepository,
},
],
}).compile();
service = module.get<WebhookService>(WebhookService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('normalizeTargetUrl', () => {
it('should normalize valid URLs', () => {
const result = (service as any).normalizeTargetUrl(
'https://example.com/webhook',
);
expect(result).toBe('https://example.com/webhook');
});
it('should return original string if invalid URL', () => {
const invalidUrl = 'not-a-url';
const result = (service as any).normalizeTargetUrl(invalidUrl);
expect(result).toBe(invalidUrl);
});
it('should normalize URL with trailing slash', () => {
const result = (service as any).normalizeTargetUrl(
'https://example.com/webhook/',
);
expect(result).toBe('https://example.com/webhook/');
});
});
describe('validateTargetUrl', () => {
it('should validate HTTPS URLs', () => {
const result = (service as any).validateTargetUrl(
'https://example.com/webhook',
);
expect(result).toBe(true);
});
it('should validate HTTP URLs', () => {
const result = (service as any).validateTargetUrl(
'http://example.com/webhook',
);
expect(result).toBe(true);
});
it('should reject invalid URLs', () => {
const result = (service as any).validateTargetUrl('not-a-url');
expect(result).toBe(false);
});
it('should reject non-HTTP protocols', () => {
const result = (service as any).validateTargetUrl(
'ftp://example.com/webhook',
);
expect(result).toBe(false);
});
});
describe('findByWorkspaceId', () => {
it('should find all webhooks for a workspace', async () => {
const mockWebhooks = [
mockWebhook,
{ ...mockWebhook, id: 'another-webhook' },
];
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
const result = await service.findByWorkspaceId(mockWorkspaceId);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockWebhooks);
});
});
describe('findByOperations', () => {
it('should find webhooks by operations using ArrayContains', async () => {
const operations = ['create', 'update'];
const mockWebhooks = [mockWebhook];
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
const result = await service.findByOperations(
mockWorkspaceId,
operations,
);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: operations.map((operation) => ({
workspaceId: mockWorkspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
expect(result).toEqual(mockWebhooks);
});
it('should handle single operation', async () => {
const operations = ['create'];
mockWebhookRepository.find.mockResolvedValue([mockWebhook]);
const result = await service.findByOperations(
mockWorkspaceId,
operations,
);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: [
{
workspaceId: mockWorkspaceId,
operations: ArrayContains(['create']),
deletedAt: IsNull(),
},
],
});
expect(result).toEqual([mockWebhook]);
});
});
describe('findById', () => {
it('should find a webhook by ID and workspace ID', async () => {
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
const result = await service.findById(mockWebhookId, mockWorkspaceId);
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockWebhookId,
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockWebhook);
});
it('should return null if webhook not found', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent', mockWorkspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create and save a webhook with valid target URL', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
operations: ['create', 'update'],
workspaceId: mockWorkspaceId,
};
mockWebhookRepository.create.mockReturnValue(mockWebhook);
mockWebhookRepository.save.mockResolvedValue(mockWebhook);
const result = await service.create(webhookData);
expect(mockWebhookRepository.create).toHaveBeenCalledWith({
...webhookData,
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
});
expect(mockWebhookRepository.save).toHaveBeenCalledWith(mockWebhook);
expect(result).toEqual(mockWebhook);
});
it('should throw WebhookException for invalid target URL', async () => {
const webhookData = {
targetUrl: 'invalid-url',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
await expect(service.create(webhookData)).rejects.toThrow(
WebhookException,
);
await expect(service.create(webhookData)).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
expect(mockWebhookRepository.create).not.toHaveBeenCalled();
expect(mockWebhookRepository.save).not.toHaveBeenCalled();
});
it('should throw WebhookException for webhook data without target URL', async () => {
const webhookData = {
operations: ['create'],
workspaceId: mockWorkspaceId,
};
await expect(service.create(webhookData)).rejects.toThrow(
WebhookException,
);
await expect(service.create(webhookData)).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
});
});
describe('update', () => {
it('should update an existing webhook', async () => {
const updateData = { targetUrl: 'https://updated.example.com/webhook' };
const updatedWebhook = { ...mockWebhook, ...updateData };
mockWebhookRepository.findOne
.mockResolvedValueOnce(mockWebhook)
.mockResolvedValueOnce(updatedWebhook);
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockWebhookId,
mockWorkspaceId,
updateData,
);
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
mockWebhookId,
updateData,
);
expect(result).toEqual(updatedWebhook);
});
it('should return null if webhook to update does not exist', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.update('non-existent', mockWorkspaceId, {
targetUrl: 'https://updated.example.com',
});
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should throw WebhookException for invalid target URL during update', async () => {
const updateData = { targetUrl: 'invalid-url' };
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
await expect(
service.update(mockWebhookId, mockWorkspaceId, updateData),
).rejects.toThrow(WebhookException);
await expect(
service.update(mockWebhookId, mockWorkspaceId, updateData),
).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
});
it('should update without target URL validation if targetUrl not in updateData', async () => {
const updateData = { operations: ['create', 'update', 'delete'] };
const updatedWebhook = { ...mockWebhook, ...updateData };
mockWebhookRepository.findOne
.mockResolvedValueOnce(mockWebhook)
.mockResolvedValueOnce(updatedWebhook);
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockWebhookId,
mockWorkspaceId,
updateData,
);
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
mockWebhookId,
updateData,
);
expect(result).toEqual(updatedWebhook);
});
});
describe('delete', () => {
it('should soft delete a webhook', async () => {
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
mockWebhookRepository.softDelete.mockResolvedValue({ affected: 1 });
const result = await service.delete(mockWebhookId, mockWorkspaceId);
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockWebhookId,
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(mockWebhookRepository.softDelete).toHaveBeenCalledWith(
mockWebhookId,
);
expect(result).toEqual(mockWebhook);
});
it('should return null if webhook to delete does not exist', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.delete('non-existent', mockWorkspaceId);
expect(mockWebhookRepository.softDelete).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should handle URLs with query parameters', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook?param=value',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
const normalizedWebhook = {
...mockWebhook,
targetUrl: 'https://example.com/webhook?param=value',
};
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
const result = await service.create(webhookData);
expect(result.targetUrl).toBe('https://example.com/webhook?param=value');
});
it('should handle URLs with fragments', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook#section',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
const normalizedWebhook = {
...mockWebhook,
targetUrl: 'https://example.com/webhook#section',
};
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
const result = await service.create(webhookData);
expect(result.targetUrl).toBe('https://example.com/webhook#section');
});
it('should handle empty operations array', async () => {
await service.findByOperations(mockWorkspaceId, []);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: [],
});
});
});
});
@@ -1,143 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { ArrayContains, IsNull, Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { WebhookEntity } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
@Injectable()
export class WebhookService {
constructor(
@InjectRepository(WebhookEntity)
private readonly webhookRepository: Repository<WebhookEntity>,
) {}
private normalizeTargetUrl(targetUrl: string): string {
try {
const url = new URL(targetUrl);
return url.toString();
} catch {
return targetUrl;
}
}
private validateTargetUrl(targetUrl: string): boolean {
try {
const url = new URL(targetUrl);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
async findByWorkspaceId(workspaceId: string): Promise<WebhookEntity[]> {
return this.webhookRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
});
}
async findByOperations(
workspaceId: string,
operations: string[],
): Promise<WebhookEntity[]> {
return this.webhookRepository.find({
where: operations.map((operation) => ({
workspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
}
async findById(
id: string,
workspaceId: string,
): Promise<WebhookEntity | null> {
const webhook = await this.webhookRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
return webhook || null;
}
async create(webhookData: Partial<WebhookEntity>): Promise<WebhookEntity> {
const normalizedTargetUrl = this.normalizeTargetUrl(
webhookData.targetUrl || '',
);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(
'Invalid target URL provided',
WebhookExceptionCode.INVALID_TARGET_URL,
{ userFriendlyMessage: msg`Please provide a valid HTTP or HTTPS URL.` },
);
}
const webhook = this.webhookRepository.create({
...webhookData,
targetUrl: normalizedTargetUrl,
secret: webhookData.secret,
});
return this.webhookRepository.save(webhook);
}
async update(
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<WebhookEntity>,
): Promise<WebhookEntity | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
if (isDefined(updateData.targetUrl)) {
const normalizedTargetUrl = this.normalizeTargetUrl(
updateData.targetUrl as string,
);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(
'Invalid target URL provided',
WebhookExceptionCode.INVALID_TARGET_URL,
{
userFriendlyMessage: msg`Please provide a valid HTTP or HTTPS URL.`,
},
);
}
updateData.targetUrl = normalizedTargetUrl;
}
await this.webhookRepository.update(id, updateData);
return this.findById(id, workspaceId);
}
async delete(id: string, workspaceId: string): Promise<WebhookEntity | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
await this.webhookRepository.softDelete(id);
return webhook;
}
}
@@ -31,7 +31,7 @@ import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-cred
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import {
DEFAULT_FAST_MODEL,