From bc7791871f8a1a48d5592e67b42854070f329bed Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 27 Jan 2026 16:32:33 +0100 Subject: [PATCH] Introduce webhook v2 (#17456) Migrate webhook to v2 entity --- .../src/generated-metadata/graphql.ts | 53 +-- .../twenty-front/src/generated/graphql.ts | 29 +- .../hooks/useMetadataErrorHandler.ts | 1 + .../graphql/mutations/deleteWebhook.ts | 8 +- .../developers/graphql/queries/getWebhook.ts | 4 +- .../hooks/__tests__/useWebhookForm.test.tsx | 38 +- .../developers/hooks/useWebhookForm.ts | 10 +- .../__tests__/createWebhookInput.test.ts | 10 +- .../developers/utils/createWebhookInput.ts | 6 +- ...ete-orphaned-workspace-entities.command.ts | 2 +- .../1769517102605-addUniversalToWebhook.ts | 35 ++ ...ivesralIdentiferAndApplicationIdNotNull.ts | 63 +++ ...lIdentifierAndApplicationIdNotNull.util.ts | 23 + .../listeners/entity-events-to-db.listener.ts | 4 +- .../engine/core-modules/core-engine.module.ts | 2 +- .../core-modules/message-queue/jobs.module.ts | 2 +- .../webhook/dtos/create-webhook.dto.ts | 20 - .../webhook/dtos/delete-webhook.dto.ts | 13 - .../webhook/dtos/get-webhook.dto.ts | 13 - .../webhook/dtos/update-webhook.dto.ts | 25 -- .../webhook/jobs/webhook-job.module.ts | 14 - .../core-modules/webhook/webhook.entity.ts | 52 --- .../core-modules/webhook/webhook.module.ts | 24 - .../core-modules/webhook/webhook.resolver.ts | 97 ---- .../webhook/webhook.service.spec.ts | 420 ------------------ .../core-modules/webhook/webhook.service.ts | 143 ------ .../workspace/workspace.entity.ts | 2 +- ...rties-to-compare-and-stringify.constant.ts | 4 + ...tadata-entity-by-metadata-name.constant.ts | 2 + .../all-metadata-relations.constant.ts | 7 + ...quired-metadata-for-validation.constant.ts | 1 + .../all-flat-entity-types-by-metadata-name.ts | 15 + ...a-related-metadata-names.util.spec.ts.snap | 2 + ...ata-names-children-first.util.spec.ts.snap | 1 + ...at-webhook-editable-properties.constant.ts | 8 + .../flat-webhook/flat-webhook.module.ts | 16 + ...orkspace-flat-webhook-map-cache.service.ts | 43 ++ .../types/flat-webhook-maps.type.ts | 4 + .../flat-webhook/types/flat-webhook.type.ts | 4 + ...ok-input-to-flat-webhook-to-create.util.ts | 41 ++ ...ook-input-to-flat-webhook-or-throw.util.ts | 31 ++ .../from-flat-webhook-to-webhook-dto.util.ts | 17 + ...to-flat-webhook-to-update-or-throw.util.ts | 41 ++ ...rom-webhook-entity-to-flat-webhook.util.ts | 20 + .../metadata-engine.module.ts | 3 + .../webhook/controllers/webhook.controller.ts | 49 +- .../webhook/dtos/create-webhook.input.ts | 38 ++ .../webhook/dtos/update-webhook.input.ts | 53 +++ .../webhook/dtos/webhook.dto.ts | 58 +++ .../webhook/entities/webhook.entity.ts | 42 ++ ...bhook-graphql-api-exception.interceptor.ts | 20 + .../webhook/jobs/call-webhook-jobs.job.ts | 34 +- .../webhook/jobs/call-webhook.job.ts | 0 .../webhook/jobs/webhook-job.module.ts | 20 + .../workspace-event-batch-for-webhook.type.ts | 0 ...form-event-batch-to-webhook-events.spec.ts | 4 +- .../transform-event-to-webhook-event.spec.ts | 2 +- .../utils/generate-webhook-secret.util.ts | 5 + ...transform-event-batch-to-webhook-events.ts | 6 +- .../utils/transform-event-to-webhook-event.ts | 0 ...hook-graphql-api-exception-handler.util.ts | 12 +- .../webhook/webhook.exception.ts | 8 +- .../webhook/webhook.module.ts | 38 ++ .../webhook/webhook.resolver.ts | 70 +++ .../webhook/webhook.service.ts | 224 ++++++++++ .../types/workspace-cache-key.type.ts | 1 + .../workspace-event-emitter.service.ts | 2 +- ...ce-migration-build-orchestrator.service.ts | 40 ++ ...workspace-migration-webhook-action.type.ts | 9 + ...gration-webhook-actions-builder.service.ts | 108 +++++ .../flat-webhook-validator.service.ts | 147 ++++++ ...ace-migration-builder-validators.module.ts | 3 + .../workspace-migration-builder.module.ts | 3 + .../create-webhook-action-handler.service.ts | 38 ++ .../delete-webhook-action-handler.service.ts | 44 ++ .../update-webhook-action-handler.service.ts | 37 ++ ...migration-runner-action-handlers.module.ts | 7 + ...ate-action-on-all-flat-entity-maps.util.ts | 3 +- ...ete-action-on-all-flat-entity-maps.util.ts | 3 +- ...ate-action-on-all-flat-entity-maps.util.ts | 3 +- .../developers/webhooks.integration-spec.ts | 26 +- .../suites/utils/webhook-test.util.ts | 18 +- .../metadata/all-metadata-name.constant.ts | 1 + 83 files changed, 1574 insertions(+), 975 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1769517102605-addUniversalToWebhook.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1769525557511-makeWebhookUnivesralIdentiferAndApplicationIdNotNull.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/utils/1769525557511-makeWebhookUniversalIdentifierAndApplicationIdNotNull.util.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/dtos/create-webhook.dto.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/dtos/delete-webhook.dto.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/dtos/get-webhook.dto.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/dtos/update-webhook.dto.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/webhook.entity.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/webhook.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/webhook.resolver.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/webhook.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/webhook/webhook.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/constants/flat-webhook-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/flat-webhook.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/services/workspace-flat-webhook-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook-maps.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-delete-webhook-input-to-flat-webhook-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-flat-webhook-to-webhook-dto.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-update-webhook-input-to-flat-webhook-to-update-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-webhook-entity-to-flat-webhook.util.ts rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/controllers/webhook.controller.ts (58%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/dtos/create-webhook.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/dtos/update-webhook.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/dtos/webhook.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/entities/webhook.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor.ts rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/jobs/call-webhook-jobs.job.ts (69%) rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/jobs/call-webhook.job.ts (100%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/types/workspace-event-batch-for-webhook.type.ts (100%) rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts (96%) rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts (86%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/utils/generate-webhook-secret.util.ts rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/utils/transform-event-batch-to-webhook-events.ts (82%) rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/utils/transform-event-to-webhook-event.ts (100%) rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/utils/webhook-graphql-api-exception-handler.util.ts (64%) rename packages/twenty-server/src/engine/{core-modules => metadata-modules}/webhook/webhook.exception.ts (71%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/webhook.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/webhook.resolver.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e1648d76d03..e13dacdf9e9 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -207,7 +207,8 @@ export enum AllMetadataName { viewField = 'viewField', viewFilter = 'viewFilter', viewFilterGroup = 'viewFilterGroup', - viewGroup = 'viewGroup' + viewGroup = 'viewGroup', + webhook = 'webhook' } export type Analytics = { @@ -1148,6 +1149,7 @@ export type CreateViewSortInput = { export type CreateWebhookInput = { description?: InputMaybe; + id?: InputMaybe; operations: Array; secret?: InputMaybe; targetUrl: Scalars['String']; @@ -1287,10 +1289,6 @@ export type DeleteViewGroupInput = { id: Scalars['UUID']; }; -export type DeleteWebhookInput = { - id: Scalars['UUID']; -}; - export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -1691,10 +1689,6 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; -export type GetWebhookInput = { - id: Scalars['UUID']; -}; - /** Order by options for graph widgets */ export enum GraphOrderBy { FIELD_ASC = 'FIELD_ASC', @@ -2075,7 +2069,7 @@ export type Mutation = { deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; deleteUserFromWorkspace: UserWorkspace; - deleteWebhook: Scalars['Boolean']; + deleteWebhook: Webhook; deleteWorkflowVersionEdge: WorkflowVersionStepChanges; deleteWorkflowVersionStep: WorkflowVersionStepChanges; deleteWorkspaceInvitation: Scalars['String']; @@ -2165,7 +2159,7 @@ export type Mutation = { updatePasswordViaResetToken: InvalidatePasswordOutput; updateSkill: Skill; updateUserEmail: Scalars['Boolean']; - updateWebhook?: Maybe; + updateWebhook: Webhook; updateWorkflowRunStep: WorkflowAction; updateWorkflowVersionPositions: Scalars['Boolean']; updateWorkflowVersionStep: WorkflowAction; @@ -2592,7 +2586,7 @@ export type MutationDeleteUserFromWorkspaceArgs = { export type MutationDeleteWebhookArgs = { - input: DeleteWebhookInput; + id: Scalars['UUID']; }; @@ -4017,7 +4011,7 @@ export type QueryValidatePasswordResetTokenArgs = { export type QueryWebhookArgs = { - input: GetWebhookInput; + id: Scalars['UUID']; }; export type QueueJob = { @@ -5001,8 +4995,14 @@ export type UpdateViewSortInput = { }; export type UpdateWebhookInput = { - description?: InputMaybe; + /** The id of the webhook to update */ id: Scalars['UUID']; + /** The webhook fields to update */ + update: UpdateWebhookInputUpdates; +}; + +export type UpdateWebhookInputUpdates = { + description?: InputMaybe; operations?: InputMaybe>; secret?: InputMaybe; targetUrl?: InputMaybe; @@ -5246,6 +5246,7 @@ export enum ViewVisibility { export type Webhook = { __typename?: 'Webhook'; + applicationId: Scalars['UUID']; createdAt: Scalars['DateTime']; deletedAt?: Maybe; description?: Maybe; @@ -6345,11 +6346,11 @@ export type CreateWebhookMutationVariables = Exact<{ export type CreateWebhookMutation = { __typename?: 'Mutation', createWebhook: { __typename?: 'Webhook', id: string, targetUrl: string, operations: Array, description?: string | null, secret: string } }; export type DeleteWebhookMutationVariables = Exact<{ - input: DeleteWebhookInput; + id: Scalars['UUID']; }>; -export type DeleteWebhookMutation = { __typename?: 'Mutation', deleteWebhook: boolean }; +export type DeleteWebhookMutation = { __typename?: 'Mutation', deleteWebhook: { __typename?: 'Webhook', id: string, targetUrl: string, operations: Array, description?: string | null, secret: string } }; export type RevokeApiKeyMutationVariables = Exact<{ input: RevokeApiKeyInput; @@ -6370,7 +6371,7 @@ export type UpdateWebhookMutationVariables = Exact<{ }>; -export type UpdateWebhookMutation = { __typename?: 'Mutation', updateWebhook?: { __typename?: 'Webhook', id: string, targetUrl: string, operations: Array, description?: string | null, secret: string } | null }; +export type UpdateWebhookMutation = { __typename?: 'Mutation', updateWebhook: { __typename?: 'Webhook', id: string, targetUrl: string, operations: Array, description?: string | null, secret: string } }; export type GetApiKeyQueryVariables = Exact<{ input: GetApiKeyInput; @@ -6385,7 +6386,7 @@ export type GetApiKeysQueryVariables = Exact<{ [key: string]: never; }>; export type GetApiKeysQuery = { __typename?: 'Query', apiKeys: Array<{ __typename?: 'ApiKey', id: string, name: string, expiresAt: string, revokedAt?: string | null, role: { __typename?: 'Role', id: string, label: string, icon?: string | null } }> }; export type GetWebhookQueryVariables = Exact<{ - input: GetWebhookInput; + id: Scalars['UUID']; }>; @@ -11989,10 +11990,12 @@ export type CreateWebhookMutationHookResult = ReturnType; export type CreateWebhookMutationOptions = Apollo.BaseMutationOptions; export const DeleteWebhookDocument = gql` - mutation DeleteWebhook($input: DeleteWebhookInput!) { - deleteWebhook(input: $input) + mutation DeleteWebhook($id: UUID!) { + deleteWebhook(id: $id) { + ...WebhookFragment + } } - `; + ${WebhookFragmentFragmentDoc}`; export type DeleteWebhookMutationFn = Apollo.MutationFunction; /** @@ -12008,7 +12011,7 @@ export type DeleteWebhookMutationFn = Apollo.MutationFunction; export type GetApiKeysLazyQueryHookResult = ReturnType; export type GetApiKeysQueryResult = Apollo.QueryResult; export const GetWebhookDocument = gql` - query GetWebhook($input: GetWebhookInput!) { - webhook(input: $input) { + query GetWebhook($id: UUID!) { + webhook(id: $id) { ...WebhookFragment } } @@ -12208,7 +12211,7 @@ export const GetWebhookDocument = gql` * @example * const { data, loading, error } = useGetWebhookQuery({ * variables: { - * input: // value for 'input' + * id: // value for 'id' * }, * }); */ diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index f4fb83326d2..344dab7e60a 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -207,7 +207,8 @@ export enum AllMetadataName { viewField = 'viewField', viewFilter = 'viewFilter', viewFilterGroup = 'viewFilterGroup', - viewGroup = 'viewGroup' + viewGroup = 'viewGroup', + webhook = 'webhook' } export type Analytics = { @@ -1115,6 +1116,7 @@ export type CreateViewSortInput = { export type CreateWebhookInput = { description?: InputMaybe; + id?: InputMaybe; operations: Array; secret?: InputMaybe; targetUrl: Scalars['String']; @@ -1254,10 +1256,6 @@ export type DeleteViewGroupInput = { id: Scalars['UUID']; }; -export type DeleteWebhookInput = { - id: Scalars['UUID']; -}; - export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -1658,10 +1656,6 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; -export type GetWebhookInput = { - id: Scalars['UUID']; -}; - /** Order by options for graph widgets */ export enum GraphOrderBy { FIELD_ASC = 'FIELD_ASC', @@ -2033,7 +2027,7 @@ export type Mutation = { deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; deleteUserFromWorkspace: UserWorkspace; - deleteWebhook: Scalars['Boolean']; + deleteWebhook: Webhook; deleteWorkflowVersionEdge: WorkflowVersionStepChanges; deleteWorkflowVersionStep: WorkflowVersionStepChanges; deleteWorkspaceInvitation: Scalars['String']; @@ -2118,7 +2112,7 @@ export type Mutation = { updatePageLayoutWithTabsAndWidgets: PageLayout; updatePasswordViaResetToken: InvalidatePasswordOutput; updateUserEmail: Scalars['Boolean']; - updateWebhook?: Maybe; + updateWebhook: Webhook; updateWorkflowRunStep: WorkflowAction; updateWorkflowVersionPositions: Scalars['Boolean']; updateWorkflowVersionStep: WorkflowAction; @@ -2500,7 +2494,7 @@ export type MutationDeleteUserFromWorkspaceArgs = { export type MutationDeleteWebhookArgs = { - input: DeleteWebhookInput; + id: Scalars['UUID']; }; @@ -3820,7 +3814,7 @@ export type QueryValidatePasswordResetTokenArgs = { export type QueryWebhookArgs = { - input: GetWebhookInput; + id: Scalars['UUID']; }; export type QueueJob = { @@ -4770,8 +4764,14 @@ export type UpdateViewSortInput = { }; export type UpdateWebhookInput = { - description?: InputMaybe; + /** The id of the webhook to update */ id: Scalars['UUID']; + /** The webhook fields to update */ + update: UpdateWebhookInputUpdates; +}; + +export type UpdateWebhookInputUpdates = { + description?: InputMaybe; operations?: InputMaybe>; secret?: InputMaybe; targetUrl?: InputMaybe; @@ -5015,6 +5015,7 @@ export enum ViewVisibility { export type Webhook = { __typename?: 'Webhook'; + applicationId: Scalars['UUID']; createdAt: Scalars['DateTime']; deletedAt?: Maybe; description?: Maybe; diff --git a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts index 1c71fd0731b..1821d15f32e 100644 --- a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts +++ b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts @@ -45,6 +45,7 @@ export const useMetadataErrorHandler = () => { commandMenuItem: t`command menu item`, frontComponent: t`front component`, navigationMenuItem: t`navigation menu item`, + webhook: t`webhook`, } as const satisfies Record; const handleMetadataError = ( diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/deleteWebhook.ts b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/deleteWebhook.ts index e7714a82dd1..5f4abedc8c2 100644 --- a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/deleteWebhook.ts +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/deleteWebhook.ts @@ -1,7 +1,11 @@ import gql from 'graphql-tag'; +import { WEBHOOK_FRAGMENT } from '@/settings/developers/graphql/fragments/webhookFragment'; export const DELETE_WEBHOOK = gql` - mutation DeleteWebhook($input: DeleteWebhookInput!) { - deleteWebhook(input: $input) + mutation DeleteWebhook($id: UUID!) { + deleteWebhook(id: $id) { + ...WebhookFragment + } } + ${WEBHOOK_FRAGMENT} `; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhook.ts b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhook.ts index d530b84d162..4ec2f65d688 100644 --- a/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhook.ts +++ b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhook.ts @@ -2,8 +2,8 @@ import gql from 'graphql-tag'; import { WEBHOOK_FRAGMENT } from '@/settings/developers/graphql/fragments/webhookFragment'; export const GET_WEBHOOK = gql` - query GetWebhook($input: GetWebhookInput!) { - webhook(input: $input) { + query GetWebhook($id: UUID!) { + webhook(id: $id) { ...WebhookFragment } } diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx b/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx index c6d1684475c..9cea8accee2 100644 --- a/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx +++ b/packages/twenty-front/src/modules/settings/developers/hooks/__tests__/useWebhookForm.test.tsx @@ -61,11 +61,13 @@ const createSuccessfulUpdateMock = (webhookId: string, webhookData = {}) => ({ variables: { input: { id: webhookId, - targetUrl: 'https://updated.com/webhook', - operations: ['person.updated'], - description: 'Updated webhook', - secret: 'updated-secret', - ...webhookData, + update: { + targetUrl: 'https://updated.com/webhook', + operations: ['person.updated'], + description: 'Updated webhook', + secret: 'updated-secret', + ...webhookData, + }, }, }, }, @@ -87,16 +89,12 @@ const createSuccessfulDeleteMock = (webhookId: string) => ({ request: { query: DELETE_WEBHOOK, variables: { - input: { - id: webhookId, - }, + id: webhookId, }, }, result: { data: { - deleteWebhook: { - id: webhookId, - }, + deleteWebhook: createMockWebhookData({ id: webhookId }), }, }, }); @@ -105,9 +103,7 @@ const createGetWebhookMock = (webhookId: string, webhookData = {}) => ({ request: { query: GET_WEBHOOK, variables: { - input: { - id: webhookId, - }, + id: webhookId, }, }, result: { @@ -329,10 +325,12 @@ describe('useWebhookForm', () => { variables: { input: { id: webhookId, - targetUrl: 'https://test.com/webhook', - operations: ['person.created'], - description: 'Test webhook', - secret: 'test-secret', + update: { + targetUrl: 'https://test.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }, }, }, }, @@ -464,9 +462,7 @@ describe('useWebhookForm', () => { request: { query: DELETE_WEBHOOK, variables: { - input: { - id: webhookId, - }, + id: webhookId, }, }, error: new Error('Deletion failed'), diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts index 42ab13453a6..e66bcf248c1 100644 --- a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts +++ b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts @@ -55,7 +55,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { const { loading, error } = useGetWebhookQuery({ skip: isCreationMode || !webhookId, variables: { - input: { id: webhookId || '' }, + id: webhookId || '', }, onCompleted: (data) => { const webhook = data.webhook; @@ -119,8 +119,10 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { } try { - const input = createWebhookUpdateInput(formValues, webhookId); - const { data } = await updateWebhook({ variables: { input } }); + const input = createWebhookUpdateInput(formValues); + const { data } = await updateWebhook({ + variables: { input: { id: webhookId, update: input } }, + }); const updatedWebhook = data?.updateWebhook; formConfig.reset(formValues); @@ -182,7 +184,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { try { await deleteWebhook({ - variables: { input: { id: webhookId } }, + variables: { id: webhookId }, }); enqueueSuccessSnackBar({ message: t`Webhook deleted successfully`, diff --git a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/createWebhookInput.test.ts b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/createWebhookInput.test.ts index d7ed9eea5a9..29c7ba86042 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/createWebhookInput.test.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/createWebhookInput.test.ts @@ -1,8 +1,8 @@ -import { type WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema'; import { createWebhookCreateInput, createWebhookUpdateInput, } from '@/settings/developers/utils/createWebhookInput'; +import { type WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema'; describe('createWebhookInput', () => { const mockFormValues: WebhookFormValues = { @@ -43,11 +43,9 @@ describe('createWebhookInput', () => { describe('createWebhookUpdateInput', () => { it('should create input for webhook update with id', () => { - const webhookId = 'test-webhook-id'; - const result = createWebhookUpdateInput(mockFormValues, webhookId); + const result = createWebhookUpdateInput(mockFormValues); expect(result).toEqual({ - id: 'test-webhook-id', targetUrl: 'https://test.com/webhook', operations: ['person.created', 'company.updated'], description: 'Test webhook', @@ -60,12 +58,10 @@ describe('createWebhookInput', () => { ...mockFormValues, targetUrl: ' https://example.com ', }; - const webhookId = 'test-webhook-id'; - const result = createWebhookUpdateInput(formValues, webhookId); + const result = createWebhookUpdateInput(formValues); expect(result.targetUrl).toBe('https://example.com'); - expect(result.id).toBe('test-webhook-id'); }); }); }); diff --git a/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts b/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts index 934753d724e..6ef012465db 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts @@ -12,14 +12,10 @@ export const createWebhookCreateInput = (formValues: WebhookFormValues) => { }; }; -export const createWebhookUpdateInput = ( - formValues: WebhookFormValues, - webhookId: string, -) => { +export const createWebhookUpdateInput = (formValues: WebhookFormValues) => { const cleanedOperations = cleanAndFormatOperations(formValues.operations); return { - id: webhookId, targetUrl: formValues.targetUrl.trim(), operations: cleanedOperations, description: formValues.description, diff --git a/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts b/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts index cb7bd008d41..4abeca2d619 100644 --- a/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts +++ b/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts @@ -21,7 +21,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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity'; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1769517102605-addUniversalToWebhook.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769517102605-addUniversalToWebhook.ts new file mode 100644 index 00000000000..f9da5be189e --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769517102605-addUniversalToWebhook.ts @@ -0,0 +1,35 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddUniversalToWebhook1769517102605 implements MigrationInterface { + name = 'AddUniversalToWebhook1769517102605'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."webhook" ADD "universalIdentifier" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ADD "applicationId" uuid`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d48d713d01cc3c81bad1f39795" ON "core"."webhook" ("workspaceId", "universalIdentifier") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ADD CONSTRAINT "FK_e755f49a9ef74b36e27932f7a6c" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."webhook" DROP CONSTRAINT "FK_e755f49a9ef74b36e27932f7a6c"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_d48d713d01cc3c81bad1f39795"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" DROP COLUMN "applicationId"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" DROP COLUMN "universalIdentifier"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1769525557511-makeWebhookUnivesralIdentiferAndApplicationIdNotNull.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769525557511-makeWebhookUnivesralIdentiferAndApplicationIdNotNull.ts new file mode 100644 index 00000000000..8c11ece5f83 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769525557511-makeWebhookUnivesralIdentiferAndApplicationIdNotNull.ts @@ -0,0 +1,63 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +import { makeWebhookUniversalIdentifierAndApplicationIdNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1769525557511-makeWebhookUniversalIdentifierAndApplicationIdNotNull.util'; + +export class MakeWebhookUnivesralIdentiferAndApplicationIdNotNull1769525557511 + implements MigrationInterface +{ + name = 'MakeWebhookUnivesralIdentiferAndApplicationIdNotNull1769525557511'; + + public async up(queryRunner: QueryRunner): Promise { + const savepointName = + 'sp_make_webhook_universal_identifier_and_application_id_not_null'; + + try { + await queryRunner.query(`SAVEPOINT ${savepointName}`); + + await makeWebhookUniversalIdentifierAndApplicationIdNotNullQueries( + queryRunner, + ); + + await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); + } catch (e) { + try { + await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`); + await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); + } catch (rollbackError) { + // eslint-disable-next-line no-console + console.error( + 'Failed to rollback to savepoint in MakeWebhookUnivesralIdentiferAndApplicationIdNotNull1769525557511', + rollbackError, + ); + throw rollbackError; + } + + // eslint-disable-next-line no-console + console.error( + 'Swallowing MakeWebhookUnivesralIdentiferAndApplicationIdNotNull1769525557511 error', + e, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."webhook" DROP CONSTRAINT "FK_e755f49a9ef74b36e27932f7a6c"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_d48d713d01cc3c81bad1f39795"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ALTER COLUMN "applicationId" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ALTER COLUMN "universalIdentifier" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d48d713d01cc3c81bad1f39795" ON "core"."webhook" ("workspaceId", "universalIdentifier") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ADD CONSTRAINT "FK_e755f49a9ef74b36e27932f7a6c" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/utils/1769525557511-makeWebhookUniversalIdentifierAndApplicationIdNotNull.util.ts b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1769525557511-makeWebhookUniversalIdentifierAndApplicationIdNotNull.util.ts new file mode 100644 index 00000000000..8cd2dea145d --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1769525557511-makeWebhookUniversalIdentifierAndApplicationIdNotNull.util.ts @@ -0,0 +1,23 @@ +import { type QueryRunner } from 'typeorm'; + +export const makeWebhookUniversalIdentifierAndApplicationIdNotNullQueries = + async (queryRunner: QueryRunner): Promise => { + await queryRunner.query( + `ALTER TABLE "core"."webhook" DROP CONSTRAINT "FK_e755f49a9ef74b36e27932f7a6c"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_d48d713d01cc3c81bad1f39795"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ALTER COLUMN "universalIdentifier" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ALTER COLUMN "applicationId" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d48d713d01cc3c81bad1f39795" ON "core"."webhook" ("workspaceId", "universalIdentifier") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ADD CONSTRAINT "FK_e755f49a9ef74b36e27932f7a6c" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts index e8ecc7a5b43..8c25e770672 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts @@ -16,8 +16,8 @@ import { CreateAuditLogFromInternalEvent } from 'src/engine/core-modules/audit/j import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.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 { CallWebhookJobsJob } from 'src/engine/core-modules/webhook/jobs/call-webhook-jobs.job'; -import { WorkspaceEventBatchForWebhook } from 'src/engine/core-modules/webhook/types/workspace-event-batch-for-webhook.type'; +import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job'; +import { WorkspaceEventBatchForWebhook } from 'src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type'; import { CallDatabaseEventTriggerJobsJob } from 'src/engine/metadata-modules/database-event-trigger/jobs/call-database-event-trigger-jobs.job'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service'; diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index e96a00b16ad..95aafd16cb4 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index fa5bb2dc2b9..ba665c9787b 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/webhook/dtos/create-webhook.dto.ts b/packages/twenty-server/src/engine/core-modules/webhook/dtos/create-webhook.dto.ts deleted file mode 100644 index 7903abfad37..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/dtos/create-webhook.dto.ts +++ /dev/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; -} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/dtos/delete-webhook.dto.ts b/packages/twenty-server/src/engine/core-modules/webhook/dtos/delete-webhook.dto.ts deleted file mode 100644 index 2a1a6c96a2e..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/dtos/delete-webhook.dto.ts +++ /dev/null @@ -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; -} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/dtos/get-webhook.dto.ts b/packages/twenty-server/src/engine/core-modules/webhook/dtos/get-webhook.dto.ts deleted file mode 100644 index 4cc1fd42f3d..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/dtos/get-webhook.dto.ts +++ /dev/null @@ -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; -} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/dtos/update-webhook.dto.ts b/packages/twenty-server/src/engine/core-modules/webhook/dtos/update-webhook.dto.ts deleted file mode 100644 index 4febb511fc4..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/dtos/update-webhook.dto.ts +++ /dev/null @@ -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; -} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts b/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts deleted file mode 100644 index c1292e74c1a..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts +++ /dev/null @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.entity.ts b/packages/twenty-server/src/engine/core-modules/webhook/webhook.entity.ts deleted file mode 100644 index a4744671218..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/webhook.entity.ts +++ /dev/null @@ -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; -} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.module.ts b/packages/twenty-server/src/engine/core-modules/webhook/webhook.module.ts deleted file mode 100644 index 8f3634b3ee9..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/webhook.module.ts +++ /dev/null @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.resolver.ts b/packages/twenty-server/src/engine/core-modules/webhook/webhook.resolver.ts deleted file mode 100644 index 577b74c2815..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/webhook.resolver.ts +++ /dev/null @@ -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 { - return this.webhookService.findByWorkspaceId(workspace.id); - } - - @Query(() => WebhookEntity, { nullable: true }) - async webhook( - @Args('input') input: GetWebhookInput, - @AuthWorkspace() workspace: WorkspaceEntity, - ): Promise { - return this.webhookService.findById(input.id, workspace.id); - } - - @Mutation(() => WebhookEntity) - async createWebhook( - @AuthWorkspace() workspace: WorkspaceEntity, - @Args('input') input: CreateWebhookInput, - ): Promise { - 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 { - try { - const updateData: QueryDeepPartialEntity = {}; - - 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 { - const result = await this.webhookService.delete(input.id, workspace.id); - - return result !== null; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.spec.ts b/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.spec.ts deleted file mode 100644 index cee8b1d8a81..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.spec.ts +++ /dev/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); - }); - - 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: [], - }); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.ts b/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.ts deleted file mode 100644 index 37c12959152..00000000000 --- a/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.ts +++ /dev/null @@ -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, - ) {} - - 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 { - return this.webhookRepository.find({ - where: { - workspaceId, - deletedAt: IsNull(), - }, - }); - } - - async findByOperations( - workspaceId: string, - operations: string[], - ): Promise { - return this.webhookRepository.find({ - where: operations.map((operation) => ({ - workspaceId, - operations: ArrayContains([operation]), - deletedAt: IsNull(), - })), - }); - } - - async findById( - id: string, - workspaceId: string, - ): Promise { - const webhook = await this.webhookRepository.findOne({ - where: { - id, - workspaceId, - deletedAt: IsNull(), - }, - }); - - return webhook || null; - } - - async create(webhookData: Partial): Promise { - 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, - ): Promise { - 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 { - const webhook = await this.findById(id, workspaceId); - - if (!webhook) { - return null; - } - - await this.webhookRepository.softDelete(id); - - return webhook; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index c61df40609b..1d4b9be582f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts index 3a3467d6be5..6da91d20c0b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts @@ -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

; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts index 9f92b394799..52ca92405d3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts @@ -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>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts index 55758a0339c..110563283c6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts @@ -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 diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index 8e07122e704..67605b03abe 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -90,4 +90,5 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { objectMetadata: true, }, frontComponent: {}, + webhook: {}, } as const satisfies MetadataRequiredForValidation; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts index 3ce8da7588c..a1787927ee1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts @@ -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'>; + }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 5f6c7f26407..e5e03c8b6cb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -162,3 +162,5 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo "view", ] `; + +exports[`getMetadataRelatedMetadataNames should return related metadata names for webhook 1`] = `[]`; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index e7b326fb438..9258fe6651c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -22,6 +22,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with "skill", "view", "viewFilterGroup", + "webhook", "fieldMetadata", "role", "serverlessFunction", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/constants/flat-webhook-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/constants/flat-webhook-editable-properties.constant.ts new file mode 100644 index 00000000000..bb3dbbc852b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/constants/flat-webhook-editable-properties.constant.ts @@ -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)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/flat-webhook.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/flat-webhook.module.ts new file mode 100644 index 00000000000..1235f45a04f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/flat-webhook.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/services/workspace-flat-webhook-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/services/workspace-flat-webhook-map-cache.service.ts new file mode 100644 index 00000000000..bfd31872257 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/services/workspace-flat-webhook-map-cache.service.ts @@ -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 { + constructor( + @InjectRepository(WebhookEntity) + private readonly webhookRepository: Repository, + ) { + super(); + } + + async computeForCache(workspaceId: string): Promise { + 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; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook-maps.type.ts new file mode 100644 index 00000000000..b4b2a172317 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook-maps.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook.type.ts new file mode 100644 index 00000000000..018016a9173 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/types/flat-webhook.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util.ts new file mode 100644 index 00000000000..8d0d7a7b438 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-create-webhook-input-to-flat-webhook-to-create.util.ts @@ -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, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-delete-webhook-input-to-flat-webhook-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-delete-webhook-input-to-flat-webhook-or-throw.util.ts new file mode 100644 index 00000000000..512ec4efeef --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-delete-webhook-input-to-flat-webhook-or-throw.util.ts @@ -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; + 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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-flat-webhook-to-webhook-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-flat-webhook-to-webhook-dto.util.ts new file mode 100644 index 00000000000..fa2814696fb --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-flat-webhook-to-webhook-dto.util.ts @@ -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, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-update-webhook-input-to-flat-webhook-to-update-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-update-webhook-input-to-flat-webhook-to-update-or-throw.util.ts new file mode 100644 index 00000000000..66d0356011d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-update-webhook-input-to-flat-webhook-to-update-or-throw.util.ts @@ -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; + 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(), + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-webhook-entity-to-flat-webhook.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-webhook-entity-to-flat-webhook.util.ts new file mode 100644 index 00000000000..29d89b5d4d9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-webhook/utils/from-webhook-entity-to-flat-webhook.util.ts @@ -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, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index b73f3ece38c..f9127cd6ecf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/controllers/webhook.controller.ts similarity index 58% rename from packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/controllers/webhook.controller.ts index f2dbedb60a3..2bb2486f24e 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/controllers/webhook.controller.ts @@ -13,20 +13,16 @@ import { 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'; +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'; -/** - * 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, @@ -40,15 +36,15 @@ export class WebhookController { @Get() async findAll( @AuthWorkspace() workspace: WorkspaceEntity, - ): Promise { - return this.webhookService.findByWorkspaceId(workspace.id); + ): Promise { + return this.webhookService.findAll(workspace.id); } @Get(':id') async findOne( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, - ): Promise { + ): Promise { return this.webhookService.findById(id, workspace.id); } @@ -56,23 +52,28 @@ export class WebhookController { async create( @Body() createWebhookDto: CreateWebhookInput, @AuthWorkspace() workspace: WorkspaceEntity, - ): Promise { - return this.webhookService.create({ - targetUrl: createWebhookDto.targetUrl, - operations: createWebhookDto.operations || ['*.*'], - description: createWebhookDto.description, - secret: createWebhookDto.secret, - workspaceId: workspace.id, - }); + ): Promise { + return this.webhookService.create(createWebhookDto, workspace.id); } @Patch(':id') async update( @Param('id') id: string, - @Body() updateWebhookDto: UpdateWebhookInput, + @Body() + updateWebhookDto: { + targetUrl?: string; + operations?: string[]; + description?: string; + secret?: string; + }, @AuthWorkspace() workspace: WorkspaceEntity, - ): Promise { - return this.webhookService.update(id, workspace.id, updateWebhookDto); + ): Promise { + const input: UpdateWebhookInput = { + id, + update: updateWebhookDto, + }; + + return this.webhookService.update(input, workspace.id); } @Delete(':id') @@ -80,8 +81,8 @@ export class WebhookController { @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - const result = await this.webhookService.delete(id, workspace.id); + await this.webhookService.delete(id, workspace.id); - return result !== null; + return true; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/create-webhook.input.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/create-webhook.input.ts new file mode 100644 index 00000000000..87f121e00ce --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/create-webhook.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/update-webhook.input.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/update-webhook.input.ts new file mode 100644 index 00000000000..e8996ae2fa6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/update-webhook.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/webhook.dto.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/webhook.dto.ts new file mode 100644 index 00000000000..956f914172c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/dtos/webhook.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/entities/webhook.entity.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/entities/webhook.entity.ts new file mode 100644 index 00000000000..f450d8f67e6 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/entities/webhook.entity.ts @@ -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 +{ + @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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor.ts new file mode 100644 index 00000000000..e6d79e723ff --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor.ts @@ -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 { + return next.handle().pipe(catchError(webhookGraphqlApiExceptionHandler)); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook-jobs.job.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts similarity index 69% rename from packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook-jobs.job.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts index 60758682c4d..ea592dff2e5 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook-jobs.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook-jobs.job.ts @@ -1,6 +1,8 @@ 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'; @@ -12,10 +14,10 @@ import { MessageQueueService } from 'src/engine/core-modules/message-queue/servi import { CallWebhookJob, type CallWebhookJobData, -} from 'src/engine/core-modules/webhook/jobs/call-webhook.job'; -import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service'; +} 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'; -import { transformEventBatchToWebhookEvents } from 'src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events'; const WEBHOOK_JOBS_CHUNK_SIZE = 20; @@ -25,7 +27,8 @@ export class CallWebhookJobsJob { constructor( @InjectMessageQueue(MessageQueue.webhookQueue) private readonly messageQueueService: MessageQueueService, - private readonly webhookService: WebhookService, + @InjectRepository(WebhookEntity) + private readonly webhookRepository: Repository, ) {} @Process(CallWebhookJobsJob.name) @@ -39,15 +42,20 @@ export class CallWebhookJobsJob { const [nameSingular, operation] = workspaceEventBatch.name.split('.'); - const webhooks = await this.webhookService.findByOperations( - workspaceEventBatch.workspaceId, - [ - `${nameSingular}.${operation}`, - `*.${operation}`, - `${nameSingular}.*`, - '*.*', - ], - ); + 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, diff --git a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/jobs/call-webhook.job.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts new file mode 100644 index 00000000000..2abc0a00d15 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/jobs/webhook-job.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/types/workspace-event-batch-for-webhook.type.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/webhook/types/workspace-event-batch-for-webhook.type.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type.ts diff --git a/packages/twenty-server/src/engine/core-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts similarity index 96% rename from packages/twenty-server/src/engine/core-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts index 8d8facc1d27..c6d30b44ed8 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts @@ -1,8 +1,8 @@ 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 { 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 = { diff --git a/packages/twenty-server/src/engine/core-modules/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts similarity index 86% rename from packages/twenty-server/src/engine/core-modules/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts index 33c966d578a..bfcb95395b4 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-to-webhook-event.spec.ts @@ -1,6 +1,6 @@ import type { ObjectRecordEvent } from 'twenty-shared/database-events'; -import { transformEventToWebhookEvent } from 'src/engine/core-modules/webhook/utils/transform-event-to-webhook-event'; +import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event'; describe('transformEventToWebhookEvent', () => { it('should transform event to webhook event', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/utils/generate-webhook-secret.util.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/generate-webhook-secret.util.ts new file mode 100644 index 00000000000..a163a273d82 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/generate-webhook-secret.util.ts @@ -0,0 +1,5 @@ +import { randomBytes } from 'crypto'; + +export const generateWebhookSecret = (): string => { + return randomBytes(32).toString('hex'); +}; diff --git a/packages/twenty-server/src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts similarity index 82% rename from packages/twenty-server/src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts index ca5d9979354..495605b3c4d 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/utils/transform-event-batch-to-webhook-events.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-batch-to-webhook-events.ts @@ -1,9 +1,9 @@ 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'; -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, diff --git a/packages/twenty-server/src/engine/core-modules/webhook/utils/transform-event-to-webhook-event.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/webhook/utils/transform-event-to-webhook-event.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event.ts diff --git a/packages/twenty-server/src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts similarity index 64% rename from packages/twenty-server/src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts index 4290ec30034..6acd78ffca5 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts @@ -1,23 +1,25 @@ 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/core-modules/webhook/webhook.exception'; +} 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.message); + throw new NotFoundError(error); + case WebhookExceptionCode.INVALID_WEBHOOK_INPUT: case WebhookExceptionCode.INVALID_TARGET_URL: - throw new UserInputError(error.message, { - userFriendlyMessage: error.userFriendlyMessage, - }); + throw new UserInputError(error); + case WebhookExceptionCode.WEBHOOK_ALREADY_EXISTS: + throw new ConflictError(error); default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.exception.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.exception.ts similarity index 71% rename from packages/twenty-server/src/engine/core-modules/webhook/webhook.exception.ts rename to packages/twenty-server/src/engine/metadata-modules/webhook/webhook.exception.ts index 311b54ebd8e..8fad9701356 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/webhook.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.exception.ts @@ -6,6 +6,8 @@ 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', } @@ -13,8 +15,12 @@ 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.`; + return msg`Invalid target URL. Please provide a valid HTTP or HTTPS URL.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.module.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.module.ts new file mode 100644 index 00000000000..cf937097063 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.resolver.ts new file mode 100644 index 00000000000..1fe5bc10b59 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.resolver.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return await this.webhookService.delete(id, workspace.id); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts new file mode 100644 index 00000000000..6bba08ffde5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/webhook.service.ts @@ -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, + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index a505f03caca..19cef17d0e1 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -46,6 +46,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatRowLevelPermissionPredicateGroupMaps: 'flat-maps:row-level-permission-predicate-group', flatFrontComponentMaps: 'flat-maps:front-component', + flatWebhookMaps: 'flat-maps:webhook', flatWorkspaceMemberMaps: 'flat-maps:workspace-member', serverlessFunctionLayerMaps: 'cache:serverless-function-layer', applicationVariableMaps: 'cache:application-variable', diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts index 50cc3631baa..dd9fac3900a 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts @@ -10,7 +10,7 @@ import { combineFilters, isDefined } from 'twenty-shared/utils'; import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; -import { transformEventToWebhookEvent } from 'src/engine/core-modules/webhook/utils/transform-event-to-webhook-event'; +import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 84172205e7a..91fa31ea770 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -38,6 +38,7 @@ import { WorkspaceMigrationViewFilterGroupActionsBuilderService } from 'src/engi import { WorkspaceMigrationViewFilterActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-filter/workspace-migration-view-filter-actions-builder.service'; import { WorkspaceMigrationViewGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-group/workspace-migration-view-group-actions-builder.service'; import { WorkspaceMigrationViewActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/workspace-migration-view-actions-builder.service'; +import { WorkspaceMigrationWebhookActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service'; @Injectable() export class WorkspaceMigrationBuildOrchestratorService { @@ -66,6 +67,7 @@ export class WorkspaceMigrationBuildOrchestratorService { private readonly workspaceMigrationRowLevelPermissionPredicateActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService, private readonly workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, private readonly workspaceMigrationFrontComponentActionsBuilderService: WorkspaceMigrationFrontComponentActionsBuilderService, + private readonly workspaceMigrationWebhookActionsBuilderService: WorkspaceMigrationWebhookActionsBuilderService, ) {} private setupOptimisticCache({ @@ -168,6 +170,7 @@ export class WorkspaceMigrationBuildOrchestratorService { flatPageLayoutWidgetMaps, flatPageLayoutTabMaps, flatFrontComponentMaps, + flatWebhookMaps, } = fromToAllFlatEntityMaps; if (isDefined(flatObjectMetadataMaps)) { @@ -1049,6 +1052,37 @@ export class WorkspaceMigrationBuildOrchestratorService { } } + if (isDefined(flatWebhookMaps)) { + const { from: fromFlatWebhookMaps, to: toFlatWebhookMaps } = + flatWebhookMaps; + + const webhookResult = + await this.workspaceMigrationWebhookActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatWebhookMaps, + to: toFlatWebhookMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: undefined, + workspaceId, + }, + ); + + this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation( + { + allFlatEntityMaps: optimisticAllFlatEntityMaps, + flatEntityMapsAndRelatedFlatEntityMaps: + webhookResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + }, + ); + + if (webhookResult.status === 'fail') { + orchestratorFailureReport.webhook.push(...webhookResult.errors); + } else { + orchestratorActionsReport.webhook = webhookResult.actions; + } + } + const allErrors = Object.values(orchestratorFailureReport); if (allErrors.some((report) => report.length > 0)) { @@ -1200,6 +1234,12 @@ export class WorkspaceMigrationBuildOrchestratorService { ...aggregatedOrchestratorActionsReport.frontComponent.create, ...aggregatedOrchestratorActionsReport.frontComponent.update, /// + + // Webhooks + ...aggregatedOrchestratorActionsReport.webhook.delete, + ...aggregatedOrchestratorActionsReport.webhook.create, + ...aggregatedOrchestratorActionsReport.webhook.update, + /// ], workspaceId, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type.ts new file mode 100644 index 00000000000..0e60610c1ee --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type.ts @@ -0,0 +1,9 @@ +import { type BaseCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-create-workspace-migration-action.type'; +import { type BaseDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-delete-workspace-migration-action.type'; +import { type BaseUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-update-workspace-migration-action.type'; + +export type CreateWebhookAction = BaseCreateWorkspaceMigrationAction<'webhook'>; + +export type UpdateWebhookAction = BaseUpdateWorkspaceMigrationAction<'webhook'>; + +export type DeleteWebhookAction = BaseDeleteWorkspaceMigrationAction<'webhook'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service.ts new file mode 100644 index 00000000000..d6296492be9 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UpdateWebhookAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type'; +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; +import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-result.type'; +import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service'; + +@Injectable() +export class WorkspaceMigrationWebhookActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.webhook +> { + constructor( + private readonly flatWebhookValidatorService: FlatWebhookValidatorService, + ) { + super(ALL_METADATA_NAME.webhook); + } + + protected validateFlatEntityCreation( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.webhook, + 'create' + > { + const validationResult = + this.flatWebhookValidatorService.validateFlatWebhookCreation(args); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatWebhookToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'webhook', + flatEntity: flatWebhookToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.webhook, + 'delete' + > { + const validationResult = + this.flatWebhookValidatorService.validateFlatWebhookDeletion(args); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatWebhookToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'webhook', + universalIdentifier: flatWebhookToValidate.universalIdentifier, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.webhook, + 'update' + > { + const validationResult = + this.flatWebhookValidatorService.validateFlatWebhookUpdate(args); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityId, flatEntityUpdates } = args; + + const updateWebhookAction: UpdateWebhookAction = { + type: 'update', + metadataName: 'webhook', + entityId: flatEntityId, + updates: flatEntityUpdates, + }; + + return { + status: 'success', + action: updateWebhookAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service.ts new file mode 100644 index 00000000000..d465b906875 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { WebhookExceptionCode } from 'src/engine/metadata-modules/webhook/webhook.exception'; +import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util'; +import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; + +@Injectable() +export class FlatWebhookValidatorService { + private validateTargetUrl(targetUrl: string): boolean { + try { + const url = new URL(targetUrl); + + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + } + + public validateFlatWebhookCreation({ + flatEntityToValidate: flatWebhook, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.webhook + >): FailedFlatEntityValidation<'webhook', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatWebhook.id, + universalIdentifier: flatWebhook.universalIdentifier, + targetUrl: flatWebhook.targetUrl, + }, + metadataName: 'webhook', + type: 'create', + }); + + if (!isNonEmptyString(flatWebhook.targetUrl)) { + validationResult.errors.push({ + code: WebhookExceptionCode.INVALID_WEBHOOK_INPUT, + message: t`Target URL is required`, + userFriendlyMessage: msg`Target URL is required`, + }); + } + + if (!this.validateTargetUrl(flatWebhook.targetUrl)) { + validationResult.errors.push({ + code: WebhookExceptionCode.INVALID_TARGET_URL, + message: t`Invalid target URL provided`, + userFriendlyMessage: msg`Please provide a valid HTTP or HTTPS URL`, + }); + } + + return validationResult; + } + + public validateFlatWebhookDeletion({ + flatEntityToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatWebhookMaps: optimisticFlatWebhookMaps, + }, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.webhook + >): FailedFlatEntityValidation<'webhook', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityToValidate.id, + universalIdentifier: flatEntityToValidate.universalIdentifier, + targetUrl: flatEntityToValidate.targetUrl, + }, + metadataName: 'webhook', + type: 'delete', + }); + + const existingWebhook = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatEntityToValidate.id, + flatEntityMaps: optimisticFlatWebhookMaps, + }); + + if (!isDefined(existingWebhook)) { + validationResult.errors.push({ + code: WebhookExceptionCode.WEBHOOK_NOT_FOUND, + message: t`Webhook not found`, + userFriendlyMessage: msg`Webhook not found`, + }); + } + + return validationResult; + } + + public validateFlatWebhookUpdate({ + flatEntityId, + flatEntityUpdates, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatWebhookMaps: optimisticFlatWebhookMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.webhook + >): FailedFlatEntityValidation<'webhook', 'update'> { + const fromFlatWebhook = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId, + flatEntityMaps: optimisticFlatWebhookMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityId, + universalIdentifier: fromFlatWebhook?.universalIdentifier, + }, + metadataName: 'webhook', + type: 'update', + }); + + if (!isDefined(fromFlatWebhook)) { + validationResult.errors.push({ + code: WebhookExceptionCode.WEBHOOK_NOT_FOUND, + message: t`Webhook not found`, + userFriendlyMessage: msg`Webhook not found`, + }); + + return validationResult; + } + + const targetUrlUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates, + property: 'targetUrl', + }); + + if ( + isDefined(targetUrlUpdate) && + !this.validateTargetUrl(targetUrlUpdate.to) + ) { + validationResult.errors.push({ + code: WebhookExceptionCode.INVALID_TARGET_URL, + message: t`Invalid target URL provided`, + userFriendlyMessage: msg`Please provide a valid HTTP or HTTPS URL`, + }); + } + + return validationResult; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts index d8f3ff0fa68..3ed5426a85d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts @@ -27,6 +27,7 @@ import { FlatViewFilterGroupValidatorService } from 'src/engine/workspace-manage import { FlatViewFilterValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service'; import { FlatViewGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-group-validator.service'; import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service'; +import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service'; @Module({ imports: [FeatureFlagModule], @@ -57,6 +58,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatRowLevelPermissionPredicateValidatorService, FlatRowLevelPermissionPredicateGroupValidatorService, FlatFrontComponentValidatorService, + FlatWebhookValidatorService, ], exports: [ FlatViewValidatorService, @@ -84,6 +86,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatRowLevelPermissionPredicateValidatorService, FlatRowLevelPermissionPredicateGroupValidatorService, FlatFrontComponentValidatorService, + FlatWebhookValidatorService, ], }) export class WorkspaceMigrationBuilderValidatorsModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts index 7a4cfd4791b..152df7c0b30 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts @@ -22,6 +22,7 @@ import { WorkspaceMigrationServerlessFunctionActionsBuilderService } from 'src/e import { WorkspaceMigrationSkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/skill/workspace-migration-skill-actions-builder.service'; import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationViewFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/workspace-migration-view-field-actions-builder.service'; +import { WorkspaceMigrationWebhookActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service'; import { WorkspaceMigrationViewFilterGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-filter-group/workspace-migration-view-filter-group-actions-builder.service'; import { WorkspaceMigrationViewFilterActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-filter/workspace-migration-view-filter-actions-builder.service'; import { WorkspaceMigrationViewGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-group/workspace-migration-view-group-actions-builder.service'; @@ -56,6 +57,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService, WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, WorkspaceMigrationFrontComponentActionsBuilderService, + WorkspaceMigrationWebhookActionsBuilderService, ], exports: [ WorkspaceMigrationViewActionsBuilderService, @@ -83,6 +85,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, FlatFieldMetadataTypeValidatorService, WorkspaceMigrationFrontComponentActionsBuilderService, + WorkspaceMigrationWebhookActionsBuilderService, ], }) export class WorkspaceMigrationBuilderModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service.ts new file mode 100644 index 00000000000..ad3d35dce30 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; +import { CreateWebhookAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateWebhookActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'webhook', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { flatEntity } = action; + + const webhookRepository = + queryRunner.manager.getRepository(WebhookEntity); + + await webhookRepository.insert({ + ...flatEntity, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service.ts new file mode 100644 index 00000000000..ca42075dcbf --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; +import { DeleteWebhookAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteWebhookActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'webhook', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId, allFlatEntityMaps } = context; + const { universalIdentifier } = action; + + const flatWebhook = findFlatEntityByUniversalIdentifierOrThrow({ + flatEntityMaps: allFlatEntityMaps.flatWebhookMaps, + universalIdentifier, + }); + + const webhookRepository = + queryRunner.manager.getRepository(WebhookEntity); + + await webhookRepository.delete({ + id: flatWebhook.id, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service.ts new file mode 100644 index 00000000000..21741a7f600 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; +import { UpdateWebhookAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/types/workspace-migration-webhook-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; + +@Injectable() +export class UpdateWebhookActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'webhook', +) { + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId, updates } = action; + + const webhookRepository = + queryRunner.manager.getRepository(WebhookEntity); + + await webhookRepository.update( + { id: entityId, workspaceId }, + fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ + updates, + }), + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index 64d8f18865c..a7f5e3a8778 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -58,6 +58,9 @@ import { CreateFrontComponentActionHandlerService } from 'src/engine/workspace-m import { UpdateFrontComponentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/update-front-component-action-handler.service'; import { DeleteFrontComponentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/delete-front-component-action-handler.service'; import { UpdateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/skill/services/update-skill-action-handler.service'; +import { CreateWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service'; +import { DeleteWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service'; +import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service'; import { CreateViewFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/create-view-field-action-handler.service'; import { DeleteViewFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/delete-view-field-action-handler.service'; import { UpdateViewFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/update-view-field-action-handler.service'; @@ -173,6 +176,10 @@ import { FunctionBuildModule } from 'src/engine/metadata-modules/function-build/ CreateFrontComponentActionHandlerService, UpdateFrontComponentActionHandlerService, DeleteFrontComponentActionHandlerService, + + CreateWebhookActionHandlerService, + UpdateWebhookActionHandlerService, + DeleteWebhookActionHandlerService, ], }) export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts index 19271773ba8..4222fe9e6a9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts @@ -71,7 +71,8 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = < case 'pageLayoutWidget': case 'pageLayoutTab': case 'commandMenuItem': - case 'frontComponent': { + case 'frontComponent': + case 'webhook': { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({ flatEntity: action.flatEntity, flatEntityAndRelatedMapsToMutate: allFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts index ec4a789cf4b..1898bf2b691 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts @@ -48,7 +48,8 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = < case 'pageLayoutWidget': case 'pageLayoutTab': case 'commandMenuItem': - case 'frontComponent': { + case 'frontComponent': + case 'webhook': { const flatEntityToDelete = findFlatEntityByUniversalIdentifierOrThrow< MetadataFlatEntity >({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts index f12fd02b3b0..9e3286b4fff 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts @@ -70,7 +70,8 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = < case 'pageLayoutWidget': case 'pageLayoutTab': case 'commandMenuItem': - case 'frontComponent': { + case 'frontComponent': + case 'webhook': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( action.metadataName, ); diff --git a/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts index ecc89dde856..fe731d183c7 100644 --- a/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts @@ -11,6 +11,8 @@ import { import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util'; import { v4 as uuidv4 } from 'uuid'; +import { type UpdateWebhookInput } from 'src/engine/metadata-modules/webhook/dtos/update-webhook.input'; + const CREATE_CONFIG_VARIABLE_MUTATION = gql` mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) { createDatabaseConfigVariable(key: $key, value: $value) @@ -139,12 +141,14 @@ describe('webhooksResolver (e2e)', () => { createdWebhookId = createdWebhookData.id; - const updateInput = { + const updateInput: UpdateWebhookInput = { id: createdWebhookData.id, - targetUrl: 'https://updated.com/webhook', - operations: ['person.updated', 'company.created'], - description: 'Updated webhook', - secret: 'updated-secret', + update: { + targetUrl: 'https://updated.com/webhook', + operations: ['person.updated', 'company.created'], + description: 'Updated webhook', + secret: 'updated-secret', + }, }; const updateResponse = await updateWebhook(updateInput); @@ -156,10 +160,14 @@ describe('webhooksResolver (e2e)', () => { const updatedWebhookData = updateResponse.body.data.updateWebhook; expect(updatedWebhookData.id).toBe(createdWebhookData.id); - expect(updatedWebhookData.targetUrl).toBe(updateInput.targetUrl); - expect(updatedWebhookData.operations).toEqual(updateInput.operations); - expect(updatedWebhookData.description).toBe(updateInput.description); - expect(updatedWebhookData.secret).toBe(updateInput.secret); + expect(updatedWebhookData.targetUrl).toBe(updateInput.update.targetUrl); + expect(updatedWebhookData.operations).toEqual( + updateInput.update.operations, + ); + expect(updatedWebhookData.description).toBe( + updateInput.update.description, + ); + expect(updatedWebhookData.secret).toBe(updateInput.update.secret); }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts b/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts index 37c76b74927..3ae930cb328 100644 --- a/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts @@ -17,14 +17,20 @@ const CREATE_WEBHOOK_MUTATION = gql` `; const DELETE_WEBHOOK_MUTATION = gql` - mutation DeleteWebhook($input: DeleteWebhookInput!) { - deleteWebhook(input: $input) + mutation DeleteWebhook($id: UUID!) { + deleteWebhook(id: $id) { + id + targetUrl + operations + description + secret + } } `; const GET_WEBHOOK_QUERY = gql` - query GetWebhook($input: GetWebhookInput!) { - webhook(input: $input) { + query GetWebhook($id: UUID!) { + webhook(id: $id) { id targetUrl operations @@ -81,14 +87,14 @@ export const createWebhook = (input: WebhookInput) => { export const deleteWebhook = (id: string) => { return makeMetadataAPIRequest({ query: DELETE_WEBHOOK_MUTATION, - variables: { input: { id } }, + variables: { id }, }); }; export const getWebhook = (id: string) => { return makeMetadataAPIRequest({ query: GET_WEBHOOK_QUERY, - variables: { input: { id } }, + variables: { id }, }); }; diff --git a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts index 98315e4edea..68722940aab 100644 --- a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts @@ -23,4 +23,5 @@ export const ALL_METADATA_NAME = { commandMenuItem: 'commandMenuItem', navigationMenuItem: 'navigationMenuItem', frontComponent: 'frontComponent', + webhook: 'webhook', } as const;