@@ -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';
|
||||
|
||||
-87
@@ -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 {}
|
||||
-9
@@ -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'>;
|
||||
};
|
||||
-249
@@ -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);
|
||||
});
|
||||
});
|
||||
-35
@@ -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);
|
||||
});
|
||||
});
|
||||
-52
@@ -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;
|
||||
};
|
||||
-35
@@ -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 }),
|
||||
};
|
||||
};
|
||||
-28
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user