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
@@ -179,6 +179,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
propertiesToCompare: [...FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
webhook: {
propertiesToCompare: ['targetUrl', 'operations', 'description', 'secret'],
propertiesToStringify: ['operations'],
},
} as const satisfies {
[P in AllMetadataName]: OneFlatEntityConfiguration<P>;
};
@@ -8,6 +8,7 @@ import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
@@ -51,4 +52,5 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = {
agent: AgentEntity,
commandMenuItem: CommandMenuItemEntity,
navigationMenuItem: NavigationMenuItemEntity,
webhook: WebhookEntity,
} as const satisfies Record<AllMetadataName, EntityTarget<ObjectLiteral>>;
@@ -478,6 +478,13 @@ export const ALL_METADATA_RELATIONS = {
},
oneToMany: {},
},
webhook: {
manyToOne: {
workspace: null,
application: null,
},
oneToMany: {},
},
} as const satisfies MetadataRelationsProperties;
// Note: satisfies with complex mapped types involving nested generics doesn't always catch missing required keys
@@ -90,4 +90,5 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
objectMetadata: true,
},
frontComponent: {},
webhook: {},
} as const satisfies MetadataRequiredForValidation;
@@ -6,6 +6,7 @@ import { type MetadataEntity } from 'src/engine/metadata-modules/flat-entity/typ
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
@@ -144,6 +145,11 @@ import {
type DeleteViewAction,
type UpdateViewAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/types/workspace-migration-view-action.type';
import {
type CreateWebhookAction,
type DeleteWebhookAction,
type UpdateWebhookAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type';
export type AllFlatEntityTypesByMetadataName = {
fieldMetadata: {
@@ -362,4 +368,13 @@ export type AllFlatEntityTypesByMetadataName = {
flatEntity: FlatFrontComponent;
entity: MetadataEntity<'frontComponent'>;
};
webhook: {
actions: {
create: CreateWebhookAction;
update: UpdateWebhookAction;
delete: DeleteWebhookAction;
};
flatEntity: FlatWebhook;
entity: MetadataEntity<'webhook'>;
};
};
@@ -162,3 +162,5 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
"view",
]
`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for webhook 1`] = `[]`;
@@ -22,6 +22,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
"skill",
"view",
"viewFilterGroup",
"webhook",
"fieldMetadata",
"role",
"serverlessFunction",
@@ -0,0 +1,8 @@
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
export const FLAT_WEBHOOK_EDITABLE_PROPERTIES = [
'targetUrl',
'operations',
'description',
'secret',
] as const satisfies (keyof FlatWebhook)[];
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceFlatWebhookMapCacheService } from 'src/engine/metadata-modules/flat-webhook/services/workspace-flat-webhook-map-cache.service';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
@Module({
imports: [
TypeOrmModule.forFeature([WebhookEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [WorkspaceFlatWebhookMapCacheService],
exports: [WorkspaceFlatWebhookMapCacheService],
})
export class FlatWebhookModule {}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatWebhookMaps } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook-maps.type';
import { fromWebhookEntityToFlatWebhook } from 'src/engine/metadata-modules/flat-webhook/utils/from-webhook-entity-to-flat-webhook.util';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatWebhookMaps')
export class WorkspaceFlatWebhookMapCacheService extends WorkspaceCacheProvider<FlatWebhookMaps> {
constructor(
@InjectRepository(WebhookEntity)
private readonly webhookRepository: Repository<WebhookEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatWebhookMaps> {
const webhooks = await this.webhookRepository.find({
where: { workspaceId, deletedAt: IsNull() },
});
const flatWebhookMaps = createEmptyFlatEntityMaps();
for (const webhookEntity of webhooks) {
const flatWebhook = fromWebhookEntityToFlatWebhook(webhookEntity);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatWebhook,
flatEntityMapsToMutate: flatWebhookMaps,
});
}
return flatWebhookMaps;
}
}
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
export type FlatWebhookMaps = FlatEntityMaps<FlatWebhook>;
@@ -0,0 +1,4 @@
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
export type FlatWebhook = FlatEntityFrom<WebhookEntity>;
@@ -0,0 +1,41 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { type CreateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/create-webhook.input';
import { generateWebhookSecret } from 'src/engine/metadata-modules/webhook/utils/generate-webhook-secret.util';
export const fromCreateWebhookInputToFlatWebhookToCreate = ({
createWebhookInput,
workspaceId,
applicationId,
}: {
createWebhookInput: CreateWebhookInput;
workspaceId: string;
applicationId: string;
}): FlatWebhook => {
const now = new Date().toISOString();
const { targetUrl, description } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
createWebhookInput,
['targetUrl', 'description'],
);
const id = createWebhookInput.id ?? v4();
const secret = createWebhookInput.secret ?? generateWebhookSecret();
return {
id,
targetUrl,
operations: createWebhookInput.operations,
description: description ?? null,
secret,
workspaceId,
createdAt: now,
updatedAt: now,
deletedAt: null,
universalIdentifier: id,
applicationId,
};
};
@@ -0,0 +1,31 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import {
WebhookException,
WebhookExceptionCode,
} from 'src/engine/metadata-modules/webhook/webhook.exception';
export const fromDeleteWebhookInputToFlatWebhookOrThrow = ({
flatWebhookMaps,
webhookId,
}: {
flatWebhookMaps: FlatEntityMaps<FlatWebhook>;
webhookId: string;
}): FlatWebhook => {
const existingFlatWebhook = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: webhookId,
flatEntityMaps: flatWebhookMaps,
});
if (!isDefined(existingFlatWebhook)) {
throw new WebhookException(
'Webhook not found',
WebhookExceptionCode.WEBHOOK_NOT_FOUND,
);
}
return existingFlatWebhook;
};
@@ -0,0 +1,17 @@
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { type WebhookDTO } from 'src/engine/metadata-modules/webhook/dtos/webhook.dto';
export const fromFlatWebhookToWebhookDto = (
flatWebhook: FlatWebhook,
): WebhookDTO => ({
id: flatWebhook.id,
targetUrl: flatWebhook.targetUrl,
operations: flatWebhook.operations,
description: flatWebhook.description,
secret: flatWebhook.secret,
workspaceId: flatWebhook.workspaceId,
applicationId: flatWebhook.applicationId,
createdAt: new Date(flatWebhook.createdAt),
updatedAt: new Date(flatWebhook.updatedAt),
deletedAt: flatWebhook.deletedAt ? new Date(flatWebhook.deletedAt) : null,
});
@@ -0,0 +1,41 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FLAT_WEBHOOK_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-webhook/constants/flat-webhook-editable-properties.constant';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { type UpdateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/update-webhook.input';
import {
WebhookException,
WebhookExceptionCode,
} from 'src/engine/metadata-modules/webhook/webhook.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateWebhookInputToFlatWebhookToUpdateOrThrow = ({
flatWebhookMaps,
updateWebhookInput,
}: {
flatWebhookMaps: FlatEntityMaps<FlatWebhook>;
updateWebhookInput: UpdateWebhookInput;
}): FlatWebhook => {
const existingFlatWebhook = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateWebhookInput.id,
flatEntityMaps: flatWebhookMaps,
});
if (!isDefined(existingFlatWebhook)) {
throw new WebhookException(
'Webhook not found',
WebhookExceptionCode.WEBHOOK_NOT_FOUND,
);
}
return {
...mergeUpdateInExistingRecord({
existing: existingFlatWebhook,
properties: [...FLAT_WEBHOOK_EDITABLE_PROPERTIES],
update: updateWebhookInput.update,
}),
updatedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,20 @@
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { type WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
export const fromWebhookEntityToFlatWebhook = (
webhookEntity: WebhookEntity,
): FlatWebhook => {
return {
id: webhookEntity.id,
targetUrl: webhookEntity.targetUrl,
operations: webhookEntity.operations,
description: webhookEntity.description,
secret: webhookEntity.secret,
workspaceId: webhookEntity.workspaceId,
universalIdentifier: webhookEntity.universalIdentifier,
applicationId: webhookEntity.applicationId,
createdAt: webhookEntity.createdAt.toISOString(),
updatedAt: webhookEntity.updatedAt.toISOString(),
deletedAt: webhookEntity.deletedAt?.toISOString() ?? null,
};
};
@@ -11,6 +11,7 @@ import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WebhookModule } from 'src/engine/metadata-modules/webhook/webhook.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
@@ -43,6 +44,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
RouteTriggerModule,
CronTriggerModule,
DatabaseEventTriggerModule,
WebhookModule,
],
providers: [],
exports: [
@@ -60,6 +62,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
ViewModule,
RoleModule,
PermissionsModule,
WebhookModule,
],
})
export class MetadataEngineModule {}
@@ -0,0 +1,88 @@
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 { 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';
import { CreateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/create-webhook.input';
import { UpdateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/update-webhook.input';
import { type WebhookDTO } from 'src/engine/metadata-modules/webhook/dtos/webhook.dto';
import { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
@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<WebhookDTO[]> {
return this.webhookService.findAll(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO | null> {
return this.webhookService.findById(id, workspace.id);
}
@Post()
async create(
@Body() createWebhookDto: CreateWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO> {
return this.webhookService.create(createWebhookDto, workspace.id);
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body()
updateWebhookDto: {
targetUrl?: string;
operations?: string[];
description?: string;
secret?: string;
},
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO> {
const input: UpdateWebhookInput = {
id,
update: updateWebhookDto,
};
return this.webhookService.update(input, workspace.id);
}
@Delete(':id')
async remove(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
await this.webhookService.delete(id, workspace.id);
return true;
}
}
@@ -0,0 +1,38 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateWebhookInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsNotEmpty()
@Field()
targetUrl: string;
@IsArray()
@Field(() => [String])
operations: string[];
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
secret?: string;
}
@@ -0,0 +1,53 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateWebhookInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
targetUrl?: string;
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
operations?: string[];
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
secret?: string;
}
@InputType()
export class UpdateWebhookInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType, {
description: 'The id of the webhook to update',
})
id: string;
@Type(() => UpdateWebhookInputUpdates)
@ValidateNested()
@Field(() => UpdateWebhookInputUpdates, {
description: 'The webhook fields to update',
})
update: UpdateWebhookInputUpdates;
}
@@ -0,0 +1,58 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('Webhook')
export class WebhookDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
targetUrl: string;
@IsArray()
@Field(() => [String])
operations: string[];
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
description: string | null;
@IsString()
@IsNotEmpty()
@Field()
secret: string;
@HideField()
workspaceId: string;
@Field(() => UUIDScalarType)
applicationId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
deletedAt: Date | null;
}
@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Index('IDX_WEBHOOK_WORKSPACE_ID', ['workspaceId'])
@Entity({ name: 'webhook', schema: 'core' })
export class WebhookEntity
extends SyncableEntity
implements Required<WebhookEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
targetUrl: string;
@Column('text', { array: true, default: ['*.*'] })
operations: string[];
@Column({ type: 'varchar', nullable: true })
description: string | null;
@Column({ nullable: false })
secret: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,20 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { webhookGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/webhook/utils/webhook-graphql-api-exception-handler.util';
@Injectable()
export class WebhookGraphqlApiExceptionInterceptor implements NestInterceptor {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next.handle().pipe(catchError(webhookGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,75 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import chunk from 'lodash.chunk';
import { ArrayContains, IsNull, Repository } from 'typeorm';
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/metadata-modules/webhook/jobs/call-webhook.job';
import { transformEventBatchToWebhookEvents } from 'src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
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,
@InjectRepository(WebhookEntity)
private readonly webhookRepository: Repository<WebhookEntity>,
) {}
@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 operations = [
`${nameSingular}.${operation}`,
`*.${operation}`,
`${nameSingular}.*`,
'*.*',
];
const webhooks = await this.webhookRepository.find({
where: operations.map((op) => ({
workspaceId: workspaceEventBatch.workspaceId,
operations: ArrayContains([op]),
deletedAt: IsNull(),
})),
});
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 },
);
}
}
}
@@ -0,0 +1,125 @@
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',
}),
});
}
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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 { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job';
import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job';
@Module({
imports: [
TypeOrmModule.forFeature([WebhookEntity]),
AuditModule,
MetricsModule,
ToolModule,
],
providers: [CallWebhookJobsJob, CallWebhookJob],
})
export class WebhookJobModule {}
@@ -0,0 +1,9 @@
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'>;
};
@@ -0,0 +1,249 @@
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/metadata-modules/webhook/entities/webhook.entity';
import { transformEventBatchToWebhookEvents } from 'src/engine/metadata-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);
});
});
@@ -0,0 +1,35 @@
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { transformEventToWebhookEvent } from 'src/engine/metadata-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);
});
});
@@ -0,0 +1,5 @@
import { randomBytes } from 'crypto';
export const generateWebhookSecret = (): string => {
return randomBytes(32).toString('hex');
};
@@ -0,0 +1,52 @@
import type { ObjectRecordEvent } from 'twenty-shared/database-events';
import { type WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { type CallWebhookJobData } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job';
import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
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;
};
@@ -0,0 +1,35 @@
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 }),
};
};
@@ -0,0 +1,30 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ConflictError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
WebhookException,
WebhookExceptionCode,
} from 'src/engine/metadata-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);
case WebhookExceptionCode.INVALID_WEBHOOK_INPUT:
case WebhookExceptionCode.INVALID_TARGET_URL:
throw new UserInputError(error);
case WebhookExceptionCode.WEBHOOK_ALREADY_EXISTS:
throw new ConflictError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,40 @@
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',
WEBHOOK_ALREADY_EXISTS = 'WEBHOOK_ALREADY_EXISTS',
INVALID_WEBHOOK_INPUT = 'INVALID_WEBHOOK_INPUT',
INVALID_TARGET_URL = 'INVALID_TARGET_URL',
}
const getWebhookExceptionUserFriendlyMessage = (code: WebhookExceptionCode) => {
switch (code) {
case WebhookExceptionCode.WEBHOOK_NOT_FOUND:
return msg`Webhook not found.`;
case WebhookExceptionCode.WEBHOOK_ALREADY_EXISTS:
return msg`A webhook with this configuration already exists.`;
case WebhookExceptionCode.INVALID_WEBHOOK_INPUT:
return msg`Invalid webhook input.`;
case WebhookExceptionCode.INVALID_TARGET_URL:
return msg`Invalid target URL. Please provide a valid HTTP or HTTPS 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),
});
}
}
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatWebhookModule } from 'src/engine/metadata-modules/flat-webhook/flat-webhook.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WebhookController } from 'src/engine/metadata-modules/webhook/controllers/webhook.controller';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WebhookGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor';
import { WebhookResolver } from 'src/engine/metadata-modules/webhook/webhook.resolver';
import { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
TypeOrmModule.forFeature([WebhookEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationModule,
WorkspaceCacheStorageModule,
ApplicationModule,
AuthModule,
PermissionsModule,
FlatWebhookModule,
],
controllers: [WebhookController],
providers: [
WebhookService,
WebhookResolver,
WebhookGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [WebhookService],
})
export class WebhookModule {}
@@ -0,0 +1,70 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
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 { CreateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/create-webhook.input';
import { UpdateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/update-webhook.input';
import { WebhookDTO } from 'src/engine/metadata-modules/webhook/dtos/webhook.dto';
import { WebhookGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor';
import { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard)
@UseInterceptors(
WorkspaceMigrationGraphqlApiExceptionInterceptor,
WebhookGraphqlApiExceptionInterceptor,
)
@Resolver(() => WebhookDTO)
export class WebhookResolver {
constructor(private readonly webhookService: WebhookService) {}
@Query(() => [WebhookDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS))
async webhooks(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO[]> {
return await this.webhookService.findAll(workspace.id);
}
@Query(() => WebhookDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS))
async webhook(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO | null> {
return await this.webhookService.findById(id, workspace.id);
}
@Mutation(() => WebhookDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS))
async createWebhook(
@Args('input') input: CreateWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO> {
return await this.webhookService.create(input, workspace.id);
}
@Mutation(() => WebhookDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS))
async updateWebhook(
@Args('input') input: UpdateWebhookInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO> {
return await this.webhookService.update(input, workspace.id);
}
@Mutation(() => WebhookDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS))
async deleteWebhook(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<WebhookDTO> {
return await this.webhookService.delete(id, workspace.id);
}
}
@@ -0,0 +1,224 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateWebhookInputToFlatWebhookToCreate } from 'src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util';
import { fromDeleteWebhookInputToFlatWebhookOrThrow } from 'src/engine/metadata-modules/flat-webhook/utils/from-delete-webhook-input-to-flat-webhook-or-throw.util';
import { fromFlatWebhookToWebhookDto } from 'src/engine/metadata-modules/flat-webhook/utils/from-flat-webhook-to-webhook-dto.util';
import { fromUpdateWebhookInputToFlatWebhookToUpdateOrThrow } from 'src/engine/metadata-modules/flat-webhook/utils/from-update-webhook-input-to-flat-webhook-to-update-or-throw.util';
import { fromWebhookEntityToFlatWebhook } from 'src/engine/metadata-modules/flat-webhook/utils/from-webhook-entity-to-flat-webhook.util';
import { type CreateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/create-webhook.input';
import { type UpdateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/update-webhook.input';
import { type WebhookDTO } from 'src/engine/metadata-modules/webhook/dtos/webhook.dto';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class WebhookService {
constructor(
@InjectRepository(WebhookEntity)
private readonly webhookRepository: Repository<WebhookEntity>,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
private normalizeTargetUrl(targetUrl: string): string {
try {
const url = new URL(targetUrl);
return url.toString();
} catch {
return targetUrl;
}
}
async findAll(workspaceId: string): Promise<WebhookDTO[]> {
const webhooks = await this.webhookRepository.find({
where: { workspaceId, deletedAt: IsNull() },
order: { createdAt: 'ASC' },
});
return webhooks
.map(fromWebhookEntityToFlatWebhook)
.map(fromFlatWebhookToWebhookDto);
}
async findById(id: string, workspaceId: string): Promise<WebhookDTO | null> {
const webhook = await this.webhookRepository.findOne({
where: { id, workspaceId, deletedAt: IsNull() },
});
if (!isDefined(webhook)) {
return null;
}
return fromFlatWebhookToWebhookDto(fromWebhookEntityToFlatWebhook(webhook));
}
async create(
input: CreateWebhookInput,
workspaceId: string,
): Promise<WebhookDTO> {
const normalizedTargetUrl = this.normalizeTargetUrl(input.targetUrl);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatWebhookToCreate = fromCreateWebhookInputToFlatWebhookToCreate({
createWebhookInput: {
...input,
targetUrl: normalizedTargetUrl,
},
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
webhook: {
flatEntityToCreate: [flatWebhookToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating webhook',
);
}
const { flatWebhookMaps: recomputedFlatWebhookMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatWebhookMaps'],
},
);
return fromFlatWebhookToWebhookDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatWebhookToCreate.id,
flatEntityMaps: recomputedFlatWebhookMaps,
}),
);
}
async update(
input: UpdateWebhookInput,
workspaceId: string,
): Promise<WebhookDTO> {
const normalizedInput = {
...input,
update: {
...input.update,
...(isDefined(input.update.targetUrl) && {
targetUrl: this.normalizeTargetUrl(input.update.targetUrl),
}),
},
};
const { flatWebhookMaps: existingFlatWebhookMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatWebhookMaps'],
},
);
const flatWebhookToUpdate =
fromUpdateWebhookInputToFlatWebhookToUpdateOrThrow({
flatWebhookMaps: existingFlatWebhookMaps,
updateWebhookInput: normalizedInput,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
webhook: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatWebhookToUpdate],
},
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating webhook',
);
}
const { flatWebhookMaps: recomputedFlatWebhookMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatWebhookMaps'],
},
);
return fromFlatWebhookToWebhookDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatWebhookMaps,
}),
);
}
async delete(id: string, workspaceId: string): Promise<WebhookDTO> {
const { flatWebhookMaps: existingFlatWebhookMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatWebhookMaps'],
},
);
const flatWebhookToDelete = fromDeleteWebhookInputToFlatWebhookOrThrow({
flatWebhookMaps: existingFlatWebhookMaps,
webhookId: id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
webhook: {
flatEntityToCreate: [],
flatEntityToDelete: [flatWebhookToDelete],
flatEntityToUpdate: [],
},
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting webhook',
);
}
return fromFlatWebhookToWebhookDto(flatWebhookToDelete);
}
}