diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7efd80bcd..11b3ba5f4 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -87,6 +87,18 @@ export type ApiConfig = { mutationMaximumAffectedRecords: Scalars['Float']; }; +export type ApiKey = { + __typename?: 'ApiKey'; + createdAt: Scalars['DateTime']; + expiresAt: Scalars['DateTime']; + id: Scalars['UUID']; + name: Scalars['String']; + revokedAt?: Maybe; + updatedAt: Scalars['DateTime']; + workspace: Workspace; + workspaceId: Scalars['String']; +}; + export type ApiKeyToken = { __typename?: 'ApiKeyToken'; token: Scalars['String']; @@ -435,6 +447,12 @@ export type ConnectionParametersOutput = { username: Scalars['String']; }; +export type CreateApiKeyDto = { + expiresAt: Scalars['String']; + name: Scalars['String']; + revokedAt?: InputMaybe; +}; + export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']; }; @@ -527,6 +545,13 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateWebhookDto = { + description?: InputMaybe; + operations: Array; + secret?: InputMaybe; + targetUrl: Scalars['String']; +}; + export type CreateWorkflowVersionStepInput = { /** Next step ID */ nextStepId?: InputMaybe; @@ -608,6 +633,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; +export type DeleteWebhookDto = { + id: Scalars['String']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -683,9 +712,11 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', + IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', - IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED' + IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', + IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED' } export type Field = { @@ -819,6 +850,10 @@ export type FullName = { lastName: Scalars['String']; }; +export type GetApiKeyDto = { + id: Scalars['String']; +}; + export type GetAuthorizationUrlForSsoInput = { identityProviderId: Scalars['String']; workspaceInviteHash?: InputMaybe; @@ -844,6 +879,10 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; +export type GetWebhookDto = { + id: Scalars['String']; +}; + export enum HealthIndicatorId { app = 'app', connectedAccount = 'connectedAccount', @@ -1015,6 +1054,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; + createApiKey: ApiKey; createApprovedAccessDomain: ApprovedAccessDomain; createDatabaseConfigVariable: Scalars['Boolean']; createDraftFromWorkflowVersion: WorkflowVersion; @@ -1027,6 +1067,7 @@ export type Mutation = { createOneRole: Role; createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; + createWebhook: Webhook; createWorkflowVersionStep: WorkflowAction; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; @@ -1039,6 +1080,7 @@ export type Mutation = { deleteOneServerlessFunction: ServerlessFunction; deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; + deleteWebhook: Scalars['Boolean']; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; @@ -1059,6 +1101,7 @@ export type Mutation = { renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; + revokeApiKey?: Maybe; runWorkflowVersion: WorkflowRun; saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess; sendInvitations: SendInvitationsOutput; @@ -1075,6 +1118,7 @@ export type Mutation = { syncRemoteTableSchemaChanges: RemoteTable; trackAnalytics: Analytics; unsyncRemoteTable: RemoteTable; + updateApiKey?: Maybe; updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; @@ -1084,6 +1128,7 @@ export type Mutation = { updateOneRole: Role; updateOneServerlessFunction: ServerlessFunction; updatePasswordViaResetToken: InvalidatePassword; + updateWebhook?: Maybe; updateWorkflowRunStep: WorkflowAction; updateWorkflowVersionStep: WorkflowAction; updateWorkspace: Workspace; @@ -1137,6 +1182,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApiKeyArgs = { + input: CreateApiKeyDto; +}; + + export type MutationCreateApprovedAccessDomainArgs = { input: CreateApprovedAccessDomainInput; }; @@ -1201,6 +1251,11 @@ export type MutationCreateSamlIdentityProviderArgs = { }; +export type MutationCreateWebhookArgs = { + input: CreateWebhookDto; +}; + + export type MutationCreateWorkflowVersionStepArgs = { input: CreateWorkflowVersionStepInput; }; @@ -1251,6 +1306,11 @@ export type MutationDeleteSsoIdentityProviderArgs = { }; +export type MutationDeleteWebhookArgs = { + input: DeleteWebhookDto; +}; + + export type MutationDeleteWorkflowVersionStepArgs = { input: DeleteWorkflowVersionStepInput; }; @@ -1342,6 +1402,11 @@ export type MutationResendWorkspaceInvitationArgs = { }; +export type MutationRevokeApiKeyArgs = { + input: RevokeApiKeyDto; +}; + + export type MutationRunWorkflowVersionArgs = { input: RunWorkflowVersionInput; }; @@ -1415,6 +1480,11 @@ export type MutationUnsyncRemoteTableArgs = { }; +export type MutationUpdateApiKeyArgs = { + input: UpdateApiKeyDto; +}; + + export type MutationUpdateDatabaseConfigVariableArgs = { key: Scalars['String']; value: Scalars['JSON']; @@ -1462,6 +1532,11 @@ export type MutationUpdatePasswordViaResetTokenArgs = { }; +export type MutationUpdateWebhookArgs = { + input: UpdateWebhookDto; +}; + + export type MutationUpdateWorkflowRunStepArgs = { input: UpdateWorkflowRunStepInput; }; @@ -1744,6 +1819,8 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; + apiKey?: Maybe; + apiKeys: Array; billingPortalSession: BillingSessionOutput; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; @@ -1785,6 +1862,13 @@ export type Query = { search: SearchResultConnection; validatePasswordResetToken: ValidatePasswordResetToken; versionInfo: VersionInfo; + webhook?: Maybe; + webhooks: Array; +}; + + +export type QueryApiKeyArgs = { + input: GetApiKeyDto; }; @@ -1945,6 +2029,11 @@ export type QueryValidatePasswordResetTokenArgs = { passwordResetToken: Scalars['String']; }; + +export type QueryWebhookArgs = { + input: GetWebhookDto; +}; + export type QueueMetricsData = { __typename?: 'QueueMetricsData'; data: Array; @@ -2036,6 +2125,10 @@ export type ResendEmailVerificationTokenOutput = { success: Scalars['Boolean']; }; +export type RevokeApiKeyDto = { + id: Scalars['String']; +}; + export type Role = { __typename?: 'Role'; canDestroyAllObjectRecords: Scalars['Boolean']; @@ -2398,6 +2491,13 @@ export type UpdateAgentInput = { responseFormat?: InputMaybe; }; +export type UpdateApiKeyDto = { + expiresAt?: InputMaybe; + id: Scalars['String']; + name?: InputMaybe; + revokedAt?: InputMaybe; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -2479,6 +2579,14 @@ export type UpdateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type UpdateWebhookDto = { + description?: InputMaybe; + id: Scalars['String']; + operations?: InputMaybe>; + secret?: InputMaybe; + targetUrl?: InputMaybe; +}; + export type UpdateWorkflowRunStepInput = { /** Step to update in JSON format */ step: Scalars['JSON']; @@ -2621,6 +2729,20 @@ export type VersionInfo = { latestVersion: Scalars['String']; }; +export type Webhook = { + __typename?: 'Webhook'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + description?: Maybe; + id: Scalars['UUID']; + operations: Array; + secret: Scalars['String']; + targetUrl: Scalars['String']; + updatedAt: Scalars['DateTime']; + workspace: Workspace; + workspaceId: Scalars['String']; +}; + export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']; @@ -3224,6 +3346,76 @@ export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never; export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } }; +export type ApiKeyFragmentFragment = { __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null }; + +export type WebhookFragmentFragment = { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array, description?: string | null, secret: string }; + +export type CreateApiKeyMutationVariables = Exact<{ + input: CreateApiKeyDto; +}>; + + +export type CreateApiKeyMutation = { __typename?: 'Mutation', createApiKey: { __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null } }; + +export type CreateWebhookMutationVariables = Exact<{ + input: CreateWebhookDto; +}>; + + +export type CreateWebhookMutation = { __typename?: 'Mutation', createWebhook: { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array, description?: string | null, secret: string } }; + +export type DeleteWebhookMutationVariables = Exact<{ + input: DeleteWebhookDto; +}>; + + +export type DeleteWebhookMutation = { __typename?: 'Mutation', deleteWebhook: boolean }; + +export type RevokeApiKeyMutationVariables = Exact<{ + input: RevokeApiKeyDto; +}>; + + +export type RevokeApiKeyMutation = { __typename?: 'Mutation', revokeApiKey?: { __typename?: 'ApiKey', id: any } | null }; + +export type UpdateApiKeyMutationVariables = Exact<{ + input: UpdateApiKeyDto; +}>; + + +export type UpdateApiKeyMutation = { __typename?: 'Mutation', updateApiKey?: { __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null } | null }; + +export type UpdateWebhookMutationVariables = Exact<{ + input: UpdateWebhookDto; +}>; + + +export type UpdateWebhookMutation = { __typename?: 'Mutation', updateWebhook?: { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array, description?: string | null, secret: string } | null }; + +export type GetApiKeyQueryVariables = Exact<{ + input: GetApiKeyDto; +}>; + + +export type GetApiKeyQuery = { __typename?: 'Query', apiKey?: { __typename?: 'ApiKey', createdAt: string, id: any, name: string, expiresAt: string, revokedAt?: string | null } | null }; + +export type GetApiKeysQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetApiKeysQuery = { __typename?: 'Query', apiKeys: Array<{ __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null }> }; + +export type GetWebhookQueryVariables = Exact<{ + input: GetWebhookDto; +}>; + + +export type GetWebhookQuery = { __typename?: 'Query', webhook?: { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array, description?: string | null, secret: string } | null }; + +export type GetWebhooksQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetWebhooksQuery = { __typename?: 'Query', webhooks: Array<{ __typename?: 'Webhook', id: any, targetUrl: string, operations: Array, description?: string | null, secret: string }> }; + export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ input: UpdateLabPublicFeatureFlagInput; }>; @@ -3647,6 +3839,23 @@ export const RemoteTableFieldsFragmentDoc = gql` schemaPendingUpdates } `; +export const ApiKeyFragmentFragmentDoc = gql` + fragment ApiKeyFragment on ApiKey { + id + name + expiresAt + revokedAt +} + `; +export const WebhookFragmentFragmentDoc = gql` + fragment WebhookFragment on Webhook { + id + targetUrl + operations + description + secret +} + `; export const SettingPermissionFragmentFragmentDoc = gql` fragment SettingPermissionFragment on SettingPermission { id @@ -6286,6 +6495,341 @@ export function useGetSystemHealthStatusLazyQuery(baseOptions?: Apollo.LazyQuery export type GetSystemHealthStatusQueryHookResult = ReturnType; export type GetSystemHealthStatusLazyQueryHookResult = ReturnType; export type GetSystemHealthStatusQueryResult = Apollo.QueryResult; +export const CreateApiKeyDocument = gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + ...ApiKeyFragment + } +} + ${ApiKeyFragmentFragmentDoc}`; +export type CreateApiKeyMutationFn = Apollo.MutationFunction; + +/** + * __useCreateApiKeyMutation__ + * + * To run a mutation, you first call `useCreateApiKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateApiKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createApiKeyMutation, { data, loading, error }] = useCreateApiKeyMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateApiKeyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateApiKeyDocument, options); + } +export type CreateApiKeyMutationHookResult = ReturnType; +export type CreateApiKeyMutationResult = Apollo.MutationResult; +export type CreateApiKeyMutationOptions = Apollo.BaseMutationOptions; +export const CreateWebhookDocument = gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + ...WebhookFragment + } +} + ${WebhookFragmentFragmentDoc}`; +export type CreateWebhookMutationFn = Apollo.MutationFunction; + +/** + * __useCreateWebhookMutation__ + * + * To run a mutation, you first call `useCreateWebhookMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateWebhookMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createWebhookMutation, { data, loading, error }] = useCreateWebhookMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateWebhookMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateWebhookDocument, options); + } +export type CreateWebhookMutationHookResult = ReturnType; +export type CreateWebhookMutationResult = Apollo.MutationResult; +export type CreateWebhookMutationOptions = Apollo.BaseMutationOptions; +export const DeleteWebhookDocument = gql` + mutation DeleteWebhook($input: DeleteWebhookDTO!) { + deleteWebhook(input: $input) +} + `; +export type DeleteWebhookMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteWebhookMutation__ + * + * To run a mutation, you first call `useDeleteWebhookMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteWebhookMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteWebhookMutation, { data, loading, error }] = useDeleteWebhookMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteWebhookMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteWebhookDocument, options); + } +export type DeleteWebhookMutationHookResult = ReturnType; +export type DeleteWebhookMutationResult = Apollo.MutationResult; +export type DeleteWebhookMutationOptions = Apollo.BaseMutationOptions; +export const RevokeApiKeyDocument = gql` + mutation RevokeApiKey($input: RevokeApiKeyDTO!) { + revokeApiKey(input: $input) { + id + } +} + `; +export type RevokeApiKeyMutationFn = Apollo.MutationFunction; + +/** + * __useRevokeApiKeyMutation__ + * + * To run a mutation, you first call `useRevokeApiKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRevokeApiKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [revokeApiKeyMutation, { data, loading, error }] = useRevokeApiKeyMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useRevokeApiKeyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RevokeApiKeyDocument, options); + } +export type RevokeApiKeyMutationHookResult = ReturnType; +export type RevokeApiKeyMutationResult = Apollo.MutationResult; +export type RevokeApiKeyMutationOptions = Apollo.BaseMutationOptions; +export const UpdateApiKeyDocument = gql` + mutation UpdateApiKey($input: UpdateApiKeyDTO!) { + updateApiKey(input: $input) { + ...ApiKeyFragment + } +} + ${ApiKeyFragmentFragmentDoc}`; +export type UpdateApiKeyMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateApiKeyMutation__ + * + * To run a mutation, you first call `useUpdateApiKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateApiKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateApiKeyMutation, { data, loading, error }] = useUpdateApiKeyMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateApiKeyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateApiKeyDocument, options); + } +export type UpdateApiKeyMutationHookResult = ReturnType; +export type UpdateApiKeyMutationResult = Apollo.MutationResult; +export type UpdateApiKeyMutationOptions = Apollo.BaseMutationOptions; +export const UpdateWebhookDocument = gql` + mutation UpdateWebhook($input: UpdateWebhookDTO!) { + updateWebhook(input: $input) { + ...WebhookFragment + } +} + ${WebhookFragmentFragmentDoc}`; +export type UpdateWebhookMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateWebhookMutation__ + * + * To run a mutation, you first call `useUpdateWebhookMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateWebhookMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateWebhookMutation, { data, loading, error }] = useUpdateWebhookMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateWebhookMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateWebhookDocument, options); + } +export type UpdateWebhookMutationHookResult = ReturnType; +export type UpdateWebhookMutationResult = Apollo.MutationResult; +export type UpdateWebhookMutationOptions = Apollo.BaseMutationOptions; +export const GetApiKeyDocument = gql` + query GetApiKey($input: GetApiKeyDTO!) { + apiKey(input: $input) { + ...ApiKeyFragment + createdAt + } +} + ${ApiKeyFragmentFragmentDoc}`; + +/** + * __useGetApiKeyQuery__ + * + * To run a query within a React component, call `useGetApiKeyQuery` and pass it any options that fit your needs. + * When your component renders, `useGetApiKeyQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetApiKeyQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGetApiKeyQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetApiKeyDocument, options); + } +export function useGetApiKeyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetApiKeyDocument, options); + } +export type GetApiKeyQueryHookResult = ReturnType; +export type GetApiKeyLazyQueryHookResult = ReturnType; +export type GetApiKeyQueryResult = Apollo.QueryResult; +export const GetApiKeysDocument = gql` + query GetApiKeys { + apiKeys { + ...ApiKeyFragment + } +} + ${ApiKeyFragmentFragmentDoc}`; + +/** + * __useGetApiKeysQuery__ + * + * To run a query within a React component, call `useGetApiKeysQuery` and pass it any options that fit your needs. + * When your component renders, `useGetApiKeysQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetApiKeysQuery({ + * variables: { + * }, + * }); + */ +export function useGetApiKeysQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetApiKeysDocument, options); + } +export function useGetApiKeysLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetApiKeysDocument, options); + } +export type GetApiKeysQueryHookResult = ReturnType; +export type GetApiKeysLazyQueryHookResult = ReturnType; +export type GetApiKeysQueryResult = Apollo.QueryResult; +export const GetWebhookDocument = gql` + query GetWebhook($input: GetWebhookDTO!) { + webhook(input: $input) { + ...WebhookFragment + } +} + ${WebhookFragmentFragmentDoc}`; + +/** + * __useGetWebhookQuery__ + * + * To run a query within a React component, call `useGetWebhookQuery` and pass it any options that fit your needs. + * When your component renders, `useGetWebhookQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetWebhookQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGetWebhookQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetWebhookDocument, options); + } +export function useGetWebhookLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetWebhookDocument, options); + } +export type GetWebhookQueryHookResult = ReturnType; +export type GetWebhookLazyQueryHookResult = ReturnType; +export type GetWebhookQueryResult = Apollo.QueryResult; +export const GetWebhooksDocument = gql` + query GetWebhooks { + webhooks { + ...WebhookFragment + } +} + ${WebhookFragmentFragmentDoc}`; + +/** + * __useGetWebhooksQuery__ + * + * To run a query within a React component, call `useGetWebhooksQuery` and pass it any options that fit your needs. + * When your component renders, `useGetWebhooksQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetWebhooksQuery({ + * variables: { + * }, + * }); + */ +export function useGetWebhooksQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetWebhooksDocument, options); + } +export function useGetWebhooksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetWebhooksDocument, options); + } +export type GetWebhooksQueryHookResult = ReturnType; +export type GetWebhooksLazyQueryHookResult = ReturnType; +export type GetWebhooksQueryResult = Apollo.QueryResult; export const UpdateLabPublicFeatureFlagDocument = gql` mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { updateLabPublicFeatureFlag(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 30c493ea3..b298b25d6 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -87,6 +87,18 @@ export type ApiConfig = { mutationMaximumAffectedRecords: Scalars['Float']; }; +export type ApiKey = { + __typename?: 'ApiKey'; + createdAt: Scalars['DateTime']; + expiresAt: Scalars['DateTime']; + id: Scalars['UUID']; + name: Scalars['String']; + revokedAt?: Maybe; + updatedAt: Scalars['DateTime']; + workspace: Workspace; + workspaceId: Scalars['String']; +}; + export type ApiKeyToken = { __typename?: 'ApiKeyToken'; token: Scalars['String']; @@ -435,6 +447,12 @@ export type ConnectionParametersOutput = { username: Scalars['String']; }; +export type CreateApiKeyDto = { + expiresAt: Scalars['String']; + name: Scalars['String']; + revokedAt?: InputMaybe; +}; + export type CreateApprovedAccessDomainInput = { domain: Scalars['String']; email: Scalars['String']; @@ -491,6 +509,13 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateWebhookDto = { + description?: InputMaybe; + operations: Array; + secret?: InputMaybe; + targetUrl: Scalars['String']; +}; + export type CreateWorkflowVersionStepInput = { /** Next step ID */ nextStepId?: InputMaybe; @@ -572,6 +597,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; +export type DeleteWebhookDto = { + id: Scalars['String']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -647,9 +676,11 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', + IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', - IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED' + IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', + IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED' } export type Field = { @@ -776,6 +807,10 @@ export type FullName = { lastName: Scalars['String']; }; +export type GetApiKeyDto = { + id: Scalars['String']; +}; + export type GetAuthorizationUrlForSsoInput = { identityProviderId: Scalars['String']; workspaceInviteHash?: InputMaybe; @@ -801,6 +836,10 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; +export type GetWebhookDto = { + id: Scalars['String']; +}; + export enum HealthIndicatorId { app = 'app', connectedAccount = 'connectedAccount', @@ -972,6 +1011,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; + createApiKey: ApiKey; createApprovedAccessDomain: ApprovedAccessDomain; createDatabaseConfigVariable: Scalars['Boolean']; createDraftFromWorkflowVersion: WorkflowVersion; @@ -983,6 +1023,7 @@ export type Mutation = { createOneRole: Role; createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; + createWebhook: Webhook; createWorkflowVersionStep: WorkflowAction; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; @@ -994,6 +1035,7 @@ export type Mutation = { deleteOneServerlessFunction: ServerlessFunction; deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; + deleteWebhook: Scalars['Boolean']; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; @@ -1014,6 +1056,7 @@ export type Mutation = { renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; + revokeApiKey?: Maybe; runWorkflowVersion: WorkflowRun; saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess; sendInvitations: SendInvitationsOutput; @@ -1027,6 +1070,7 @@ export type Mutation = { switchToEnterprisePlan: BillingUpdateOutput; switchToYearlyInterval: BillingUpdateOutput; trackAnalytics: Analytics; + updateApiKey?: Maybe; updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; @@ -1035,6 +1079,7 @@ export type Mutation = { updateOneRole: Role; updateOneServerlessFunction: ServerlessFunction; updatePasswordViaResetToken: InvalidatePassword; + updateWebhook?: Maybe; updateWorkflowRunStep: WorkflowAction; updateWorkflowVersionStep: WorkflowAction; updateWorkspace: Workspace; @@ -1088,6 +1133,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApiKeyArgs = { + input: CreateApiKeyDto; +}; + + export type MutationCreateApprovedAccessDomainArgs = { input: CreateApprovedAccessDomainInput; }; @@ -1137,6 +1187,11 @@ export type MutationCreateSamlIdentityProviderArgs = { }; +export type MutationCreateWebhookArgs = { + input: CreateWebhookDto; +}; + + export type MutationCreateWorkflowVersionStepArgs = { input: CreateWorkflowVersionStepInput; }; @@ -1182,6 +1237,11 @@ export type MutationDeleteSsoIdentityProviderArgs = { }; +export type MutationDeleteWebhookArgs = { + input: DeleteWebhookDto; +}; + + export type MutationDeleteWorkflowVersionStepArgs = { input: DeleteWorkflowVersionStepInput; }; @@ -1273,6 +1333,11 @@ export type MutationResendWorkspaceInvitationArgs = { }; +export type MutationRevokeApiKeyArgs = { + input: RevokeApiKeyDto; +}; + + export type MutationRunWorkflowVersionArgs = { input: RunWorkflowVersionInput; }; @@ -1331,6 +1396,11 @@ export type MutationTrackAnalyticsArgs = { }; +export type MutationUpdateApiKeyArgs = { + input: UpdateApiKeyDto; +}; + + export type MutationUpdateDatabaseConfigVariableArgs = { key: Scalars['String']; value: Scalars['JSON']; @@ -1373,6 +1443,11 @@ export type MutationUpdatePasswordViaResetTokenArgs = { }; +export type MutationUpdateWebhookArgs = { + input: UpdateWebhookDto; +}; + + export type MutationUpdateWorkflowRunStepArgs = { input: UpdateWorkflowRunStepInput; }; @@ -1655,6 +1730,8 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; + apiKey?: Maybe; + apiKeys: Array; billingPortalSession: BillingSessionOutput; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; @@ -1693,6 +1770,13 @@ export type Query = { search: SearchResultConnection; validatePasswordResetToken: ValidatePasswordResetToken; versionInfo: VersionInfo; + webhook?: Maybe; + webhooks: Array; +}; + + +export type QueryApiKeyArgs = { + input: GetApiKeyDto; }; @@ -1805,6 +1889,11 @@ export type QueryValidatePasswordResetTokenArgs = { passwordResetToken: Scalars['String']; }; + +export type QueryWebhookArgs = { + input: GetWebhookDto; +}; + export type QueueMetricsData = { __typename?: 'QueueMetricsData'; data: Array; @@ -1882,6 +1971,10 @@ export type ResendEmailVerificationTokenOutput = { success: Scalars['Boolean']; }; +export type RevokeApiKeyDto = { + id: Scalars['String']; +}; + export type Role = { __typename?: 'Role'; canDestroyAllObjectRecords: Scalars['Boolean']; @@ -2244,6 +2337,13 @@ export type UpdateAgentInput = { responseFormat?: InputMaybe; }; +export type UpdateApiKeyDto = { + expiresAt?: InputMaybe; + id: Scalars['String']; + name?: InputMaybe; + revokedAt?: InputMaybe; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -2317,6 +2417,14 @@ export type UpdateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type UpdateWebhookDto = { + description?: InputMaybe; + id: Scalars['String']; + operations?: InputMaybe>; + secret?: InputMaybe; + targetUrl?: InputMaybe; +}; + export type UpdateWorkflowRunStepInput = { /** Step to update in JSON format */ step: Scalars['JSON']; @@ -2449,6 +2557,20 @@ export type VersionInfo = { latestVersion: Scalars['String']; }; +export type Webhook = { + __typename?: 'Webhook'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + description?: Maybe; + id: Scalars['UUID']; + operations: Array; + secret: Scalars['String']; + targetUrl: Scalars['String']; + updatedAt: Scalars['DateTime']; + workspace: Workspace; + workspaceId: Scalars['String']; +}; + export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']; diff --git a/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx b/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx index e7e975ca7..6ab7e83e2 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/ApiKeyNameInput.tsx @@ -2,11 +2,9 @@ import styled from '@emotion/styled'; import { useCallback, useEffect } from 'react'; import { useDebouncedCallback } from 'use-debounce'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { TextInput } from '@/ui/input/components/TextInput'; import { isDefined } from 'twenty-shared/utils'; +import { useUpdateApiKeyMutation } from '~/generated-metadata/graphql'; const StyledComboInputContainer = styled.div` display: flex; @@ -29,9 +27,7 @@ export const ApiKeyNameInput = ({ disabled, onNameUpdate, }: ApiKeyNameInputProps) => { - const { updateOneRecord: updateApiKey } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.ApiKey, - }); + const [updateApiKey] = useUpdateApiKeyMutation(); // TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -43,10 +39,18 @@ export const ApiKeyNameInput = ({ if (!apiKeyName) { return; } - await updateApiKey({ - idToUpdate: apiKeyId, - updateOneRecordInput: { name }, + const { data: updatedApiKeyData } = await updateApiKey({ + variables: { + input: { + id: apiKeyId, + name, + }, + }, }); + const updatedApiKey = updatedApiKeyData?.updateApiKey; + if (isDefined(updatedApiKey)) { + onNameUpdate?.(updatedApiKey.name); + } }, 500), [updateApiKey, onNameUpdate], ); diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx index 9e3b080e2..57fa116af 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx @@ -1,7 +1,7 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem'; +import { formatExpiration } from '@/settings/developers/utils/formatExpiration'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; import { @@ -9,6 +9,7 @@ import { OverflowingTextWithTooltip, } from 'twenty-ui/display'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; +import { ApiKey } from '~/generated-metadata/graphql'; export const StyledApisFieldTableRow = styled(TableRow)` grid-template-columns: 312px auto 28px; @@ -34,27 +35,28 @@ const StyledIconChevronRight = styled(IconChevronRight)` `; export const SettingsApiKeysFieldItemTableRow = ({ - fieldItem, + apiKey, to, }: { - fieldItem: ApiFieldItem; + apiKey: Pick; to: string; }) => { const theme = useTheme(); + const formattedExpiration = formatExpiration(apiKey.expiresAt || null); return ( - + - {fieldItem.expiration} + {formattedExpiration} { - const { records: apiKeys } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ApiKey, - filter: { revokedAt: { is: 'NULL' } }, - }); + const { data: apiKeysData } = useGetApiKeysQuery(); + + const apiKeys = apiKeysData?.apiKeys; return ( @@ -49,14 +44,14 @@ export const SettingsApiKeysTable = () => { - {!!apiKeys.length && ( + {!!apiKeys?.length && ( - {formatExpirations(apiKeys).map((fieldItem) => ( + {apiKeys.map((apiKey) => ( ))} diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookForm.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookForm.tsx index 456f5da12..f830e2490 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookForm.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookForm.tsx @@ -79,7 +79,7 @@ export const SettingsDevelopersWebhookForm = ({ handleSave, updateOperation, removeOperation, - deleteWebhook, + handleDelete, isCreationMode, error, } = useWebhookForm({ webhookId, mode }); @@ -285,7 +285,7 @@ export const SettingsDevelopersWebhookForm = ({ Please type "yes" to confirm you want to delete this webhook. } - onConfirmClick={deleteWebhook} + onConfirmClick={handleDelete} confirmButtonText={t`Delete`} /> )} diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx index 62ccf5a42..e307b470d 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersWebhookTableRow.tsx @@ -1,11 +1,11 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { Webhook } from '@/settings/developers/types/webhook/Webhook'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils'; import { IconChevronRight } from 'twenty-ui/display'; +import { Webhook } from '~/generated-metadata/graphql'; export const StyledApisFieldTableRow = styled(TableRow)` grid-template-columns: 1fr 28px; @@ -28,10 +28,13 @@ const StyledIconChevronRight = styled(IconChevronRight)` `; export const SettingsDevelopersWebhookTableRow = ({ - fieldItem, + webhook, to, }: { - fieldItem: Webhook; + webhook: Pick< + Webhook, + 'id' | 'targetUrl' | 'operations' | 'description' | 'secret' + >; to: string; }) => { const theme = useTheme(); @@ -39,9 +42,9 @@ export const SettingsDevelopersWebhookTableRow = ({ return ( - {isValidUrl(fieldItem.targetUrl) - ? getUrlHostnameOrThrow(fieldItem.targetUrl) - : fieldItem.targetUrl} + {isValidUrl(webhook.targetUrl) + ? getUrlHostnameOrThrow(webhook.targetUrl) + : webhook.targetUrl} { - const { records: webhooks } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Webhook, - }); + const { data: webhooksData } = useGetWebhooksQuery(); + + const webhooks = webhooksData?.webhooks; return (
@@ -32,12 +30,12 @@ export const SettingsWebhooksTable = () => { URL - {!!webhooks.length && ( + {!!webhooks?.length && ( {webhooks.map((webhookFieldItem) => ( = { title: 'Modules/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow', component: SettingsApiKeysFieldItemTableRow, - decorators: [ComponentDecorator], + decorators: [ComponentDecorator, RouterDecorator], args: { - fieldItem: { + apiKey: { id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791', name: 'Zapier Api Key', - type: 'internal', - expiration: 'In 3 days', + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days from now + revokedAt: null, }, + to: '/settings/developers/api-keys/3f4a42e8-b81f-4f8c-9c20-1602e6b34791', }, }; diff --git a/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsDevelopersWebhookForm.stories.tsx b/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsDevelopersWebhookForm.stories.tsx index c4ded62a0..e50c8799b 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsDevelopersWebhookForm.stories.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/__stories__/SettingsDevelopersWebhookForm.stories.tsx @@ -35,7 +35,7 @@ export const CreateMode: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText('New Webhook', undefined, { timeout: 10000 }); + await canvas.findByText('New Webhook', undefined, { timeout: 3000 }); await canvas.findByPlaceholderText('https://example.com/webhook'); await canvas.findByPlaceholderText('Write a description'); @@ -48,15 +48,21 @@ export const EditMode: Story = { mode: WebhookFormMode.Edit, webhookId: '1234', }, - parameters: { - msw: graphqlMocks, - }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByDisplayValue('https://example.com/webhook', undefined, { - timeout: 10000, - }); - await canvas.findByDisplayValue('A Sample Description'); + await canvas.findByDisplayValue( + 'https://api.slackbot.io/webhooks/twenty', + undefined, + { + timeout: 3000, + }, + ); + await canvas.findByDisplayValue('Slack notifications for lead updates'); + + const allObjectsLabels = await canvas.findAllByText('All Objects'); + expect(allObjectsLabels).toHaveLength(2); + await canvas.findByText('Created'); + await canvas.findByText('Updated'); await canvas.findByText('Danger zone'); await canvas.findByText('Delete this webhook'); diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/fragments/apiKeyFragment.ts b/packages/twenty-front/src/modules/settings/developers/graphql/fragments/apiKeyFragment.ts new file mode 100644 index 000000000..b0075872c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/fragments/apiKeyFragment.ts @@ -0,0 +1,10 @@ +import gql from 'graphql-tag'; + +export const API_KEY_FRAGMENT = gql` + fragment ApiKeyFragment on ApiKey { + id + name + expiresAt + revokedAt + } +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/fragments/webhookFragment.ts b/packages/twenty-front/src/modules/settings/developers/graphql/fragments/webhookFragment.ts new file mode 100644 index 000000000..d1529d5c4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/fragments/webhookFragment.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; + +export const WEBHOOK_FRAGMENT = gql` + fragment WebhookFragment on Webhook { + id + targetUrl + operations + description + secret + } +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/createApiKey.ts b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/createApiKey.ts new file mode 100644 index 000000000..9bb539176 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/createApiKey.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment'; + +export const CREATE_API_KEY = gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + ...ApiKeyFragment + } + } + ${API_KEY_FRAGMENT} +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/createWebhook.ts b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/createWebhook.ts new file mode 100644 index 000000000..718a167c6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/createWebhook.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment'; + +export const CREATE_WEBHOOK = gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + ...WebhookFragment + } + } + ${WEBHOOK_FRAGMENT} +`; 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 new file mode 100644 index 000000000..094f69a09 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/deleteWebhook.ts @@ -0,0 +1,7 @@ +import gql from 'graphql-tag'; + +export const DELETE_WEBHOOK = gql` + mutation DeleteWebhook($input: DeleteWebhookDTO!) { + deleteWebhook(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/revokeApiKey.ts b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/revokeApiKey.ts new file mode 100644 index 000000000..8c3c6cc11 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/revokeApiKey.ts @@ -0,0 +1,9 @@ +import gql from 'graphql-tag'; + +export const REVOKE_API_KEY = gql` + mutation RevokeApiKey($input: RevokeApiKeyDTO!) { + revokeApiKey(input: $input) { + id + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/updateApiKey.ts b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/updateApiKey.ts new file mode 100644 index 000000000..94dd73bd7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/updateApiKey.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment'; + +export const UPDATE_API_KEY = gql` + mutation UpdateApiKey($input: UpdateApiKeyDTO!) { + updateApiKey(input: $input) { + ...ApiKeyFragment + } + } + ${API_KEY_FRAGMENT} +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/mutations/updateWebhook.ts b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/updateWebhook.ts new file mode 100644 index 000000000..fa0ece3b0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/mutations/updateWebhook.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment'; + +export const UPDATE_WEBHOOK = gql` + mutation UpdateWebhook($input: UpdateWebhookDTO!) { + updateWebhook(input: $input) { + ...WebhookFragment + } + } + ${WEBHOOK_FRAGMENT} +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKey.ts b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKey.ts new file mode 100644 index 000000000..14b8a5b59 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKey.ts @@ -0,0 +1,12 @@ +import gql from 'graphql-tag'; +import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment'; + +export const GET_API_KEY = gql` + query GetApiKey($input: GetApiKeyDTO!) { + apiKey(input: $input) { + ...ApiKeyFragment + createdAt + } + } + ${API_KEY_FRAGMENT} +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeys.ts b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeys.ts new file mode 100644 index 000000000..785950a83 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeys.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment'; + +export const GET_API_KEYS = gql` + query GetApiKeys { + apiKeys { + ...ApiKeyFragment + } + } + ${API_KEY_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 new file mode 100644 index 000000000..92b058771 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhook.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment'; + +export const GET_WEBHOOK = gql` + query GetWebhook($input: GetWebhookDTO!) { + webhook(input: $input) { + ...WebhookFragment + } + } + ${WEBHOOK_FRAGMENT} +`; diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhooks.ts b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhooks.ts new file mode 100644 index 000000000..4746392b2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getWebhooks.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; +import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment'; + +export const GET_WEBHOOKS = gql` + query GetWebhooks { + webhooks { + ...WebhookFragment + } + } + ${WEBHOOK_FRAGMENT} +`; 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 f0aa63ab3..481e4731f 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 @@ -5,16 +5,15 @@ import { MemoryRouter } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode'; -import { ApolloError } from '@apollo/client'; +import { CREATE_WEBHOOK } from '@/settings/developers/graphql/mutations/createWebhook'; +import { DELETE_WEBHOOK } from '@/settings/developers/graphql/mutations/deleteWebhook'; +import { UPDATE_WEBHOOK } from '@/settings/developers/graphql/mutations/updateWebhook'; +import { GET_WEBHOOK } from '@/settings/developers/graphql/queries/getWebhook'; import { useWebhookForm } from '../useWebhookForm'; -// Mock dependencies const mockNavigateSettings = jest.fn(); const mockEnqueueSuccessSnackBar = jest.fn(); const mockEnqueueErrorSnackBar = jest.fn(); -const mockCreateOneRecord = jest.fn(); -const mockUpdateOneRecord = jest.fn(); -const mockDeleteOneRecord = jest.fn(); jest.mock('~/hooks/useNavigateSettings', () => ({ useNavigateSettings: () => mockNavigateSettings, @@ -27,32 +26,108 @@ jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({ }), })); -jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({ - useCreateOneRecord: () => ({ - createOneRecord: mockCreateOneRecord, - }), -})); +const createMockWebhookData = (overrides = {}) => ({ + id: 'test-webhook-id', + targetUrl: 'https://test.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + ...overrides, +}); -jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({ - useUpdateOneRecord: () => ({ - updateOneRecord: mockUpdateOneRecord, - }), -})); +const createSuccessfulCreateMock = (webhookData = {}) => ({ + request: { + query: CREATE_WEBHOOK, + variables: { + input: { + targetUrl: 'https://test.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + ...webhookData, + }, + }, + }, + result: { + data: { + createWebhook: createMockWebhookData(webhookData), + }, + }, +}); -jest.mock('@/object-record/hooks/useDeleteOneRecord', () => ({ - useDeleteOneRecord: () => ({ - deleteOneRecord: mockDeleteOneRecord, - }), -})); +const createSuccessfulUpdateMock = (webhookId: string, webhookData = {}) => ({ + request: { + query: UPDATE_WEBHOOK, + variables: { + input: { + id: webhookId, + targetUrl: 'https://updated.com/webhook', + operations: ['person.updated'], + description: 'Updated webhook', + secret: 'updated-secret', + ...webhookData, + }, + }, + }, + result: { + data: { + updateWebhook: createMockWebhookData({ + id: webhookId, + targetUrl: 'https://updated.com/webhook', + operations: ['person.updated'], + description: 'Updated webhook', + secret: 'updated-secret', + ...webhookData, + }), + }, + }, +}); -jest.mock('@/object-record/hooks/useFindOneRecord', () => ({ - useFindOneRecord: () => ({ - loading: false, - }), -})); +const createSuccessfulDeleteMock = (webhookId: string) => ({ + request: { + query: DELETE_WEBHOOK, + variables: { + input: { + id: webhookId, + }, + }, + }, + result: { + data: { + deleteWebhook: { + id: webhookId, + }, + }, + }, +}); -const Wrapper = ({ children }: { children: ReactNode }) => ( - +const createGetWebhookMock = (webhookId: string, webhookData = {}) => ({ + request: { + query: GET_WEBHOOK, + variables: { + input: { + id: webhookId, + }, + }, + }, + result: { + data: { + webhook: createMockWebhookData({ + id: webhookId, + ...webhookData, + }), + }, + }, +}); + +const Wrapper = ({ + children, + mocks = [], +}: { + children: ReactNode; + mocks?: any[]; +}) => ( + {children} @@ -68,7 +143,7 @@ describe('useWebhookForm', () => { it('should initialize with default values in create mode', () => { const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { wrapper: ({ children }) => {children} }, ); expect(result.current.isCreationMode).toBe(true); @@ -81,15 +156,15 @@ describe('useWebhookForm', () => { }); it('should handle webhook creation successfully', async () => { - const mockCreatedWebhook = { - id: 'new-webhook-id', - targetUrl: 'https://test.com/webhook', - }; - mockCreateOneRecord.mockResolvedValue(mockCreatedWebhook); + const mocks = [createSuccessfulCreateMock()]; const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); const formData = { @@ -103,28 +178,36 @@ describe('useWebhookForm', () => { await result.current.handleSave(formData); }); - expect(mockCreateOneRecord).toHaveBeenCalledWith({ - id: expect.any(String), - targetUrl: 'https://test.com/webhook', - description: 'Test webhook', - operations: ['person.created'], - secret: 'test-secret', - }); - expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({ message: 'Webhook https://test.com/webhook created successfully', }); }); it('should handle creation errors', async () => { - const error = new ApolloError({ - graphQLErrors: [{ message: 'Creation failed' }], - }); - mockCreateOneRecord.mockRejectedValue(error); + const errorMock = { + request: { + query: CREATE_WEBHOOK, + variables: { + input: { + targetUrl: 'https://test.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }, + }, + }, + error: new Error('Creation failed'), + }; + + const mocks = [errorMock]; const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); const formData = { @@ -139,16 +222,24 @@ describe('useWebhookForm', () => { }); expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ - apolloError: error, + apolloError: expect.any(Error), }); }); it('should clean and format operations correctly', async () => { - mockCreateOneRecord.mockResolvedValue({ id: 'test-id' }); + const mocks = [ + createSuccessfulCreateMock({ + operations: ['person.created', 'company.updated'], + }), + ]; const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); const formData = { @@ -167,12 +258,8 @@ describe('useWebhookForm', () => { await result.current.handleSave(formData); }); - expect(mockCreateOneRecord).toHaveBeenCalledWith({ - id: expect.any(String), - targetUrl: 'https://test.com/webhook', - description: 'Test webhook', - operations: ['person.created', 'company.updated'], - secret: 'test-secret', + expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({ + message: 'Webhook https://test.com/webhook created successfully', }); }); }); @@ -181,20 +268,29 @@ describe('useWebhookForm', () => { const webhookId = 'test-webhook-id'; it('should initialize correctly in edit mode', () => { + const mocks = [createGetWebhookMock(webhookId)]; + const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Edit, webhookId, }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); expect(result.current.isCreationMode).toBe(false); }); it('should handle webhook update successfully', async () => { - mockUpdateOneRecord.mockResolvedValue({}); + const mocks = [ + createGetWebhookMock(webhookId), + createSuccessfulUpdateMock(webhookId), + ]; const { result } = renderHook( () => @@ -202,7 +298,11 @@ describe('useWebhookForm', () => { mode: WebhookFormMode.Edit, webhookId, }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); const formData = { @@ -216,22 +316,30 @@ describe('useWebhookForm', () => { await result.current.handleSave(formData); }); - expect(mockUpdateOneRecord).toHaveBeenCalledWith({ - idToUpdate: webhookId, - updateOneRecordInput: { - targetUrl: 'https://updated.com/webhook', - description: 'Updated webhook', - operations: ['person.updated'], - secret: 'updated-secret', - }, + expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({ + message: 'Webhook https://updated.com/webhook updated successfully', }); }); it('should handle update errors', async () => { - const error = new ApolloError({ - graphQLErrors: [{ message: 'Update failed' }], - }); - mockUpdateOneRecord.mockRejectedValue(error); + const getWebhookMock = createGetWebhookMock(webhookId); + const updateErrorMock = { + request: { + query: UPDATE_WEBHOOK, + variables: { + input: { + id: webhookId, + targetUrl: 'https://test.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }, + }, + }, + error: new Error('Update failed'), + }; + + const mocks = [getWebhookMock, updateErrorMock]; const { result } = renderHook( () => @@ -239,7 +347,11 @@ describe('useWebhookForm', () => { mode: WebhookFormMode.Edit, webhookId, }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); const formData = { @@ -254,7 +366,7 @@ describe('useWebhookForm', () => { }); expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ - apolloError: error, + apolloError: expect.any(Error), }); }); }); @@ -263,7 +375,7 @@ describe('useWebhookForm', () => { it('should update operations correctly', () => { const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { wrapper: ({ children }) => {children} }, ); act(() => { @@ -277,7 +389,7 @@ describe('useWebhookForm', () => { it('should remove operations correctly', () => { const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { wrapper: ({ children }) => {children} }, ); act(() => { @@ -305,7 +417,10 @@ describe('useWebhookForm', () => { const webhookId = 'test-webhook-id'; it('should delete webhook successfully', async () => { - mockDeleteOneRecord.mockResolvedValue({}); + const mocks = [ + createGetWebhookMock(webhookId), + createSuccessfulDeleteMock(webhookId), + ]; const { result } = renderHook( () => @@ -313,14 +428,17 @@ describe('useWebhookForm', () => { mode: WebhookFormMode.Edit, webhookId, }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); await act(async () => { - await result.current.deleteWebhook(); + await result.current.handleDelete(); }); - expect(mockDeleteOneRecord).toHaveBeenCalledWith(webhookId); expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({ message: 'Webhook deleted successfully', }); @@ -329,11 +447,11 @@ describe('useWebhookForm', () => { it('should handle deletion without webhookId', async () => { const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { wrapper: ({ children }) => {children} }, ); await act(async () => { - await result.current.deleteWebhook(); + await result.current.handleDelete(); }); expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ @@ -342,10 +460,19 @@ describe('useWebhookForm', () => { }); it('should handle deletion errors', async () => { - const error = new ApolloError({ - graphQLErrors: [{ message: 'Deletion failed' }], - }); - mockDeleteOneRecord.mockRejectedValue(error); + const errorMock = { + request: { + query: DELETE_WEBHOOK, + variables: { + input: { + id: webhookId, + }, + }, + }, + error: new Error('Deletion failed'), + }; + + const mocks = [createGetWebhookMock(webhookId), errorMock]; const { result } = renderHook( () => @@ -353,15 +480,19 @@ describe('useWebhookForm', () => { mode: WebhookFormMode.Edit, webhookId, }), - { wrapper: Wrapper }, + { + wrapper: ({ children }) => ( + {children} + ), + }, ); await act(async () => { - await result.current.deleteWebhook(); + await result.current.handleDelete(); }); expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ - apolloError: error, + apolloError: expect.any(Error), }); }); }); @@ -370,7 +501,7 @@ describe('useWebhookForm', () => { it('should validate canSave property', () => { const { result } = renderHook( () => useWebhookForm({ mode: WebhookFormMode.Create }), - { wrapper: Wrapper }, + { wrapper: ({ children }) => {children} }, ); // Initially canSave should be false (form is not valid) 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 fd679aa1c..223904a4a 100644 --- a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts +++ b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookForm.ts @@ -1,13 +1,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; -import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode'; -import { Webhook } from '@/settings/developers/types/webhook/Webhook'; +import { addEmptyOperationIfNecessary } from '@/settings/developers/utils/addEmptyOperationIfNecessary'; +import { + createWebhookCreateInput, + createWebhookUpdateInput, +} from '@/settings/developers/utils/createWebhookInput'; +import { parseOperationsFromStrings } from '@/settings/developers/utils/parseOperationsFromStrings'; import { webhookFormSchema, WebhookFormValues, @@ -16,100 +16,66 @@ import { SettingsPath } from '@/types/SettingsPath'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ApolloError } from '@apollo/client'; import { t } from '@lingui/core/macro'; -import { isDefined } from 'twenty-shared/utils'; -import { v4 } from 'uuid'; +import { + useCreateWebhookMutation, + useDeleteWebhookMutation, + useGetWebhookQuery, + useUpdateWebhookMutation, +} from '~/generated-metadata/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation'; -import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType'; type UseWebhookFormProps = { webhookId?: string; mode: WebhookFormMode; }; +const DEFAULT_FORM_VALUES: WebhookFormValues = { + targetUrl: '', + description: '', + operations: [{ object: '*', action: '*' }], + secret: '', +}; + export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { const navigate = useNavigateSettings(); const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); const isCreationMode = mode === WebhookFormMode.Create; - const { createOneRecord } = useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Webhook, - }); - - const { updateOneRecord } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Webhook, - }); - - const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({ - objectNameSingular: CoreObjectNameSingular.Webhook, - }); + const [createWebhook] = useCreateWebhookMutation(); + const [updateWebhook] = useUpdateWebhookMutation(); + const [deleteWebhook] = useDeleteWebhookMutation(); const formConfig = useForm({ mode: isCreationMode ? 'onSubmit' : 'onTouched', resolver: zodResolver(webhookFormSchema), - defaultValues: { - targetUrl: '', - description: '', - operations: [ - { - object: '*', - action: '*', - }, - ], - secret: '', - }, + defaultValues: DEFAULT_FORM_VALUES, }); - const addEmptyOperationIfNecessary = ( - newOperations: WebhookOperationType[], - ): WebhookOperationType[] => { - if ( - !newOperations.some((op) => op.object === '*' && op.action === '*') && - !newOperations.some((op) => op.object === null) - ) { - return [...newOperations, WEBHOOK_EMPTY_OPERATION]; - } - return newOperations; - }; - - const cleanAndFormatOperations = (operations: WebhookOperationType[]) => { - return Array.from( - new Set( - operations - .filter((op) => isDefined(op.object) && isDefined(op.action)) - .map((op) => `${op.object}.${op.action}`), - ), - ); - }; - - const { loading, error } = useFindOneRecord({ - skip: isCreationMode, - objectNameSingular: CoreObjectNameSingular.Webhook, - objectRecordId: webhookId || '', + const { loading, error } = useGetWebhookQuery({ + skip: isCreationMode || !webhookId, + variables: { + input: { id: webhookId || '' }, + }, onCompleted: (data) => { - if (!data) return; + const webhook = data.webhook; + if (!webhook) return; - const baseOperations = data?.operations - ? data.operations.map((op: string) => { - const [object, action] = op.split('.'); - return { object, action }; - }) - : data?.operation - ? [ - { - object: data.operation.split('.')[0], - action: data.operation.split('.')[1], - }, - ] - : []; + const baseOperations = webhook?.operations?.length + ? parseOperationsFromStrings(webhook.operations) + : []; const operations = addEmptyOperationIfNecessary(baseOperations); formConfig.reset({ - targetUrl: data.targetUrl || '', - description: data.description || '', + targetUrl: webhook.targetUrl || '', + description: webhook.description || '', operations, - secret: data.secret || '', + secret: webhook.secret || '', + }); + }, + onError: () => { + enqueueErrorSnackBar({ + message: t`Failed to load webhook`, }); }, }); @@ -121,19 +87,9 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { const handleCreate = async (formValues: WebhookFormValues) => { try { - const cleanedOperations = cleanAndFormatOperations(formValues.operations); - - const webhookData = { - targetUrl: formValues.targetUrl.trim(), - operations: cleanedOperations, - description: formValues.description, - secret: formValues.secret, - }; - - const createdWebhook = await createOneRecord({ - id: v4(), - ...webhookData, - }); + const input = createWebhookCreateInput(formValues); + const { data } = await createWebhook({ variables: { input } }); + const createdWebhook = data?.createWebhook; const targetUrl = createdWebhook?.targetUrl ? `${createdWebhook?.targetUrl}` @@ -163,23 +119,15 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { } try { - const cleanedOperations = cleanAndFormatOperations(formValues.operations); - - const webhookData = { - targetUrl: formValues.targetUrl.trim(), - operations: cleanedOperations, - description: formValues.description, - secret: formValues.secret, - }; - - await updateOneRecord({ - idToUpdate: webhookId, - updateOneRecordInput: webhookData, - }); + const input = createWebhookUpdateInput(formValues, webhookId); + const { data } = await updateWebhook({ variables: { input } }); + const updatedWebhook = data?.updateWebhook; formConfig.reset(formValues); - const targetUrl = webhookData.targetUrl ? `${webhookData.targetUrl}` : ''; + const targetUrl = updatedWebhook?.targetUrl + ? `${updatedWebhook.targetUrl}` + : ''; enqueueSuccessSnackBar({ message: t`Webhook ${targetUrl} updated successfully`, @@ -224,7 +172,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { ); }; - const deleteWebhook = async () => { + const handleDelete = async () => { if (!webhookId) { enqueueErrorSnackBar({ message: t`Webhook ID is required for deletion`, @@ -233,7 +181,9 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { } try { - await deleteOneWebhook(webhookId); + await deleteWebhook({ + variables: { input: { id: webhookId } }, + }); enqueueSuccessSnackBar({ message: t`Webhook deleted successfully`, }); @@ -253,7 +203,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { handleSave, updateOperation, removeOperation, - deleteWebhook, + handleDelete, isCreationMode, error, }; diff --git a/packages/twenty-front/src/modules/settings/developers/types/api-key/ApiFieldItem.ts b/packages/twenty-front/src/modules/settings/developers/types/api-key/ApiFieldItem.ts deleted file mode 100644 index 278b34488..000000000 --- a/packages/twenty-front/src/modules/settings/developers/types/api-key/ApiFieldItem.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type ApiFieldItem = { - id: string; - name: string; - type: 'internal' | 'published'; - expiration: string; -}; diff --git a/packages/twenty-front/src/modules/settings/developers/types/api-key/ApiKey.ts b/packages/twenty-front/src/modules/settings/developers/types/api-key/ApiKey.ts deleted file mode 100644 index 463f0defa..000000000 --- a/packages/twenty-front/src/modules/settings/developers/types/api-key/ApiKey.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type ApiKey = { - id: string; - createdAt: string; - updatedAt: string; - deletedAt: string | null; - name: string; - expiresAt: string; - revokedAt: string | null; - __typename: 'ApiKey'; -}; diff --git a/packages/twenty-front/src/modules/settings/developers/types/webhook/Webhook.ts b/packages/twenty-front/src/modules/settings/developers/types/webhook/Webhook.ts deleted file mode 100644 index e837e9417..000000000 --- a/packages/twenty-front/src/modules/settings/developers/types/webhook/Webhook.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type Webhook = { - id: string; - targetUrl: string; - description?: string; - operations: string[]; - secret?: string; - __typename: 'Webhook'; -}; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts new file mode 100644 index 000000000..553650f5d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/addEmptyOperationIfNecessary.test.ts @@ -0,0 +1,67 @@ +import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation'; +import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType'; +import { addEmptyOperationIfNecessary } from '../addEmptyOperationIfNecessary'; + +describe('addEmptyOperationIfNecessary', () => { + it('should add empty operation when no wildcard or null object operations exist', () => { + const operations: WebhookOperationType[] = [ + { object: 'person', action: 'created' }, + { object: 'company', action: 'updated' }, + ]; + + const result = addEmptyOperationIfNecessary(operations); + + expect(result).toEqual([ + { object: 'person', action: 'created' }, + { object: 'company', action: 'updated' }, + WEBHOOK_EMPTY_OPERATION, + ]); + }); + + it('should not add empty operation when wildcard operation exists', () => { + const operations: WebhookOperationType[] = [ + { object: '*', action: '*' }, + { object: 'person', action: 'created' }, + ]; + + const result = addEmptyOperationIfNecessary(operations); + + expect(result).toEqual([ + { object: '*', action: '*' }, + { object: 'person', action: 'created' }, + ]); + }); + + it('should not add empty operation when null object operation exists', () => { + const operations: WebhookOperationType[] = [ + { object: 'person', action: 'created' }, + { object: null, action: 'test' }, + ]; + + const result = addEmptyOperationIfNecessary(operations); + + expect(result).toEqual([ + { object: 'person', action: 'created' }, + { object: null, action: 'test' }, + ]); + }); + + it('should handle empty array by adding empty operation', () => { + const operations: WebhookOperationType[] = []; + + const result = addEmptyOperationIfNecessary(operations); + + expect(result).toEqual([WEBHOOK_EMPTY_OPERATION]); + }); + + it('should not modify original array', () => { + const operations: WebhookOperationType[] = [ + { object: 'person', action: 'created' }, + ]; + const originalLength = operations.length; + + addEmptyOperationIfNecessary(operations); + + expect(operations.length).toBe(originalLength); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/developers/utils/__tests__/cleanAndFormatOperations.test.ts b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/cleanAndFormatOperations.test.ts new file mode 100644 index 000000000..5f88b61f0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/cleanAndFormatOperations.test.ts @@ -0,0 +1,47 @@ +import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType'; +import { cleanAndFormatOperations } from '../cleanAndFormatOperations'; + +describe('cleanAndFormatOperations', () => { + it('should filter out operations with null object values', () => { + const operations: WebhookOperationType[] = [ + { object: 'person', action: 'created' }, + { object: null, action: 'test' }, + { object: 'person', action: 'updated' }, + ]; + + const result = cleanAndFormatOperations(operations); + + expect(result).toEqual(['person.created', 'person.updated']); + }); + + it('should remove duplicate operations', () => { + const operations: WebhookOperationType[] = [ + { object: 'person', action: 'created' }, + { object: 'person', action: 'created' }, + { object: 'company', action: 'updated' }, + ]; + + const result = cleanAndFormatOperations(operations); + + expect(result).toEqual(['person.created', 'company.updated']); + }); + + it('should handle empty array', () => { + const operations: WebhookOperationType[] = []; + + const result = cleanAndFormatOperations(operations); + + expect(result).toEqual([]); + }); + + it('should handle wildcard operations', () => { + const operations: WebhookOperationType[] = [ + { object: '*', action: '*' }, + { object: 'person', action: 'created' }, + ]; + + const result = cleanAndFormatOperations(operations); + + expect(result).toEqual(['*.*', 'person.created']); + }); +}); 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 new file mode 100644 index 000000000..2c03087af --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/createWebhookInput.test.ts @@ -0,0 +1,71 @@ +import { WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema'; +import { + createWebhookCreateInput, + createWebhookUpdateInput, +} from '../createWebhookInput'; + +describe('createWebhookInput', () => { + const mockFormValues: WebhookFormValues = { + targetUrl: ' https://test.com/webhook ', + description: 'Test webhook', + operations: [ + { object: 'person', action: 'created' }, + { object: 'person', action: 'created' }, // duplicate + { object: 'company', action: 'updated' }, + { object: null, action: 'test' }, // should be filtered out + ], + secret: 'test-secret', + }; + + describe('createWebhookCreateInput', () => { + it('should create input for webhook creation', () => { + const result = createWebhookCreateInput(mockFormValues); + + expect(result).toEqual({ + targetUrl: 'https://test.com/webhook', + operations: ['person.created', 'company.updated'], + description: 'Test webhook', + secret: 'test-secret', + }); + }); + + it('should trim targetUrl', () => { + const formValues: WebhookFormValues = { + ...mockFormValues, + targetUrl: ' https://example.com ', + }; + + const result = createWebhookCreateInput(formValues); + + expect(result.targetUrl).toBe('https://example.com'); + }); + }); + + describe('createWebhookUpdateInput', () => { + it('should create input for webhook update with id', () => { + const webhookId = 'test-webhook-id'; + const result = createWebhookUpdateInput(mockFormValues, webhookId); + + expect(result).toEqual({ + id: 'test-webhook-id', + targetUrl: 'https://test.com/webhook', + operations: ['person.created', 'company.updated'], + description: 'Test webhook', + secret: 'test-secret', + }); + }); + + it('should trim targetUrl and include id', () => { + const formValues: WebhookFormValues = { + ...mockFormValues, + targetUrl: ' https://example.com ', + }; + const webhookId = 'test-webhook-id'; + + const result = createWebhookUpdateInput(formValues, webhookId); + + 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/__tests__/parseOperationsFromStrings.test.ts b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/parseOperationsFromStrings.test.ts new file mode 100644 index 000000000..307aaa3f6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/__tests__/parseOperationsFromStrings.test.ts @@ -0,0 +1,42 @@ +import { parseOperationsFromStrings } from '../parseOperationsFromStrings'; + +describe('parseOperationsFromStrings', () => { + it('should parse operation strings into object/action pairs', () => { + const operations = ['person.created', 'company.updated', 'lead.deleted']; + + const result = parseOperationsFromStrings(operations); + + expect(result).toEqual([ + { object: 'person', action: 'created' }, + { object: 'company', action: 'updated' }, + { object: 'lead', action: 'deleted' }, + ]); + }); + + it('should handle wildcard operations', () => { + const operations = ['*.*', 'person.created']; + + const result = parseOperationsFromStrings(operations); + + expect(result).toEqual([ + { object: '*', action: '*' }, + { object: 'person', action: 'created' }, + ]); + }); + + it('should handle empty array', () => { + const operations: string[] = []; + + const result = parseOperationsFromStrings(operations); + + expect(result).toEqual([]); + }); + + it('should handle operations with multiple dots by taking first two parts', () => { + const operations = ['person.created.test']; + + const result = parseOperationsFromStrings(operations); + + expect(result).toEqual([{ object: 'person', action: 'created' }]); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts b/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts new file mode 100644 index 000000000..bc9a37800 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/addEmptyOperationIfNecessary.ts @@ -0,0 +1,14 @@ +import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation'; +import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType'; + +export const addEmptyOperationIfNecessary = ( + newOperations: WebhookOperationType[], +): WebhookOperationType[] => { + if ( + !newOperations.some((op) => op.object === '*' && op.action === '*') && + !newOperations.some((op) => op.object === null) + ) { + return [...newOperations, WEBHOOK_EMPTY_OPERATION]; + } + return newOperations; +}; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/cleanAndFormatOperations.ts b/packages/twenty-front/src/modules/settings/developers/utils/cleanAndFormatOperations.ts new file mode 100644 index 000000000..7291bca90 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/cleanAndFormatOperations.ts @@ -0,0 +1,15 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType'; + +export const cleanAndFormatOperations = ( + operations: WebhookOperationType[], +) => { + return Array.from( + new Set( + operations + .filter((op) => isDefined(op.object) && isDefined(op.action)) + .map((op) => `${op.object}.${op.action}`), + ), + ); +}; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts b/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts new file mode 100644 index 000000000..a6b0c0b58 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/createWebhookInput.ts @@ -0,0 +1,28 @@ +import { WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema'; +import { cleanAndFormatOperations } from './cleanAndFormatOperations'; + +export const createWebhookCreateInput = (formValues: WebhookFormValues) => { + const cleanedOperations = cleanAndFormatOperations(formValues.operations); + + return { + targetUrl: formValues.targetUrl.trim(), + operations: cleanedOperations, + description: formValues.description, + secret: formValues.secret, + }; +}; + +export const createWebhookUpdateInput = ( + formValues: WebhookFormValues, + webhookId: string, +) => { + const cleanedOperations = cleanAndFormatOperations(formValues.operations); + + return { + id: webhookId, + targetUrl: formValues.targetUrl.trim(), + operations: cleanedOperations, + description: formValues.description, + secret: formValues.secret, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts b/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts index 6b0b7a3ca..666c81aa3 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts @@ -2,8 +2,6 @@ import { isNonEmptyString } from '@sniptt/guards'; import { DateTime } from 'luxon'; import { NEVER_EXPIRE_DELTA_IN_YEARS } from '@/settings/developers/constants/NeverExpireDeltaInYears'; -import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem'; -import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { beautifyDateDiff } from '~/utils/date-utils'; export const doesNeverExpire = (expiresAt: string) => { @@ -28,16 +26,3 @@ export const formatExpiration = ( } return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`; }; - -export const formatExpirations = ( - apiKeys: Array>, -): ApiFieldItem[] => { - return apiKeys.map(({ id, name, expiresAt }) => { - return { - id, - name, - expiration: formatExpiration(expiresAt || null), - type: 'internal', - }; - }); -}; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts b/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts new file mode 100644 index 000000000..e29c0ab75 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/utils/parseOperationsFromStrings.ts @@ -0,0 +1,10 @@ +import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType'; + +export const parseOperationsFromStrings = ( + operations: string[], +): WebhookOperationType[] => { + return operations.map((op: string) => { + const [object, action] = op.split('.'); + return { object, action }; + }); +}; diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopersApiKeysDetail.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopersApiKeysDetail.stories.tsx index e93df4a71..12be01e5c 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopersApiKeysDetail.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/api-keys/SettingsDevelopersApiKeysDetail.stories.tsx @@ -1,6 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; import { fireEvent, userEvent, within } from '@storybook/test'; -import { HttpResponse, graphql } from 'msw'; import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail'; import { @@ -21,26 +20,7 @@ const meta: Meta = { }, }, parameters: { - msw: { - handlers: [ - ...graphqlMocks.handlers, - graphql.query('FindOneApiKey', () => { - return HttpResponse.json({ - data: { - apiKey: { - __typename: 'ApiKey', - id: 'f7c6d736-8fcd-4e9c-ab99-28f6a9031570', - revokedAt: null, - expiresAt: '2024-03-10T09:23:10.511Z', - name: 'sfsfdsf', - updatedAt: '2024-02-24T10:23:10.673Z', - createdAt: '2024-02-24T10:23:10.673Z', - }, - }, - }); - }), - ], - }, + msw: graphqlMocks, }, }; export default meta; @@ -50,14 +30,14 @@ export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 }); + await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 }); }, }; export const RegenerateApiKey: Story = { play: async ({ step }) => { const canvas = within(document.body); - await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 }); + await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 }); await userEvent.click(await canvas.findByText('Regenerate Key')); @@ -85,7 +65,7 @@ export const RegenerateApiKey: Story = { export const DeleteApiKey: Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 }); + await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 }); await userEvent.click(await canvas.findByText('Delete')); diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhookNew.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhookNew.stories.tsx index 5d9bee43d..b90bcde57 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhookNew.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhookNew.stories.tsx @@ -27,7 +27,7 @@ export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText('New Webhook', undefined, { timeout: 10000 }); + await canvas.findByText('New Webhook', undefined, { timeout: 3000 }); await canvas.findByText( 'We will send POST requests to this endpoint for every new event', ); diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksDetail.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksDetail.stories.tsx index 0347ee216..c6a11eb13 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksDetail.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksDetail.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; -import { within } from '@storybook/test'; +import { expect, within } from '@storybook/test'; import { PageDecorator, @@ -28,11 +28,20 @@ export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText( - 'We will send POST requests to this endpoint for every new event', + await canvas.findByDisplayValue( + 'https://api.slackbot.io/webhooks/twenty', undefined, - { timeout: 10000 }, + { + timeout: 3000, + }, ); + await canvas.findByDisplayValue('Slack notifications for lead updates'); + + const allObjectsLabels = await canvas.findAllByText('All Objects'); + expect(allObjectsLabels).toHaveLength(2); + await canvas.findByText('Created'); + await canvas.findByText('Updated'); + await canvas.findByText('Delete this webhook'); }, }; diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx index 58906de2d..93ffa15e3 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx @@ -1,19 +1,13 @@ import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; -import { DateTime } from 'luxon'; import { useState } from 'react'; import { useParams } from 'react-router-dom'; import { useRecoilCallback, useRecoilValue } from 'recoil'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput'; import { ApiKeyNameInput } from '@/settings/developers/components/ApiKeyNameInput'; import { apiKeyTokenFamilyState } from '@/settings/developers/states/apiKeyTokenFamilyState'; -import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { computeNewExpirationDate } from '@/settings/developers/utils/computeNewExpirationDate'; import { formatExpiration } from '@/settings/developers/utils/formatExpiration'; import { SettingsPath } from '@/types/SettingsPath'; @@ -23,10 +17,16 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { Trans, useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; import { H2Title, IconRepeat, IconTrash } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; -import { useGenerateApiKeyTokenMutation } from '~/generated-metadata/graphql'; +import { + useCreateApiKeyMutation, + useGenerateApiKeyTokenMutation, + useGetApiKeyQuery, + useRevokeApiKeyMutation, +} from '~/generated-metadata/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; @@ -67,30 +67,34 @@ export const SettingsDevelopersApiKeyDetail = () => { ); const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation(); - const { createOneRecord: createOneApiKey } = useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.ApiKey, - }); - const { updateOneRecord: updateApiKey } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.ApiKey, - }); - - const [apiKeyName, setApiKeyName] = useState(''); - - const { record: apiKeyData, loading } = useFindOneRecord({ - objectNameSingular: CoreObjectNameSingular.ApiKey, - objectRecordId: apiKeyId, - onCompleted: (record) => { - setApiKeyName(record.name); + const [createApiKey] = useCreateApiKeyMutation(); + const [revokeApiKey] = useRevokeApiKeyMutation(); + const { data: apiKeyData } = useGetApiKeyQuery({ + variables: { + input: { + id: apiKeyId, + }, + }, + onCompleted: (data) => { + if (isDefined(data?.apiKey)) { + setApiKeyName(data.apiKey.name); + } }, }); + const apiKey = apiKeyData?.apiKey; + const [apiKeyName, setApiKeyName] = useState(''); + const deleteIntegration = async (redirect = true) => { setIsLoading(true); try { - await updateApiKey?.({ - idToUpdate: apiKeyId, - updateOneRecordInput: { revokedAt: DateTime.now().toString() }, + await revokeApiKey({ + variables: { + input: { + id: apiKeyId, + }, + }, }); if (redirect) { navigate(SettingsPath.APIs); @@ -106,11 +110,17 @@ export const SettingsDevelopersApiKeyDetail = () => { name: string, newExpiresAt: string | null, ) => { - const newApiKey = await createOneApiKey?.({ - name: name, - expiresAt: newExpiresAt ?? '', + const { data: newApiKeyData } = await createApiKey({ + variables: { + input: { + name: name, + expiresAt: newExpiresAt ?? '', + }, + }, }); + const newApiKey = newApiKeyData?.createApiKey; + if (!newApiKey) { return; } @@ -130,18 +140,18 @@ export const SettingsDevelopersApiKeyDetail = () => { const regenerateApiKey = async () => { setIsLoading(true); try { - if (isNonEmptyString(apiKeyData?.name)) { + if (isNonEmptyString(apiKey?.name)) { const newExpiresAt = computeNewExpirationDate( - apiKeyData?.expiresAt, - apiKeyData?.createdAt, + apiKey?.expiresAt, + apiKey?.createdAt, ); - const apiKey = await createIntegration(apiKeyData?.name, newExpiresAt); + const newApiKey = await createIntegration(apiKey?.name, newExpiresAt); await deleteIntegration(false); - if (isNonEmptyString(apiKey?.token)) { - setApiKeyTokenCallback(apiKey.id, apiKey.token); + if (isNonEmptyString(newApiKey?.token)) { + setApiKeyTokenCallback(newApiKey.id, newApiKey.token); navigate(SettingsPath.ApiKeyDetail, { - apiKeyId: apiKey.id, + apiKeyId: newApiKey.id, }); } } @@ -158,9 +168,9 @@ export const SettingsDevelopersApiKeyDetail = () => { return ( <> - {apiKeyData?.name && ( + {apiKey?.name && ( { onClick={() => openModal(REGENERATE_API_KEY_MODAL_ID)} /> - {formatExpiration( - apiKeyData?.expiresAt || '', - true, - false, - )} + {formatExpiration(apiKey?.expiresAt || '', true, false)} @@ -210,8 +216,8 @@ export const SettingsDevelopersApiKeyDetail = () => { @@ -221,13 +227,9 @@ export const SettingsDevelopersApiKeyDetail = () => { description={t`When the key will be disabled`} /> diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx index 064a034c4..7b38eb487 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx @@ -1,13 +1,10 @@ import { DateTime } from 'luxon'; import { useState } from 'react'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { EXPIRATION_DATES } from '@/settings/developers/constants/ExpirationDates'; import { apiKeyTokenFamilyState } from '@/settings/developers/states/apiKeyTokenFamilyState'; -import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { SettingsPath } from '@/types/SettingsPath'; import { Select } from '@/ui/input/components/Select'; import { TextInput } from '@/ui/input/components/TextInput'; @@ -18,7 +15,10 @@ import { Key } from 'ts-key-enum'; import { isDefined } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; -import { useGenerateApiKeyTokenMutation } from '~/generated-metadata/graphql'; +import { + useCreateApiKeyMutation, + useGenerateApiKeyTokenMutation, +} from '~/generated-metadata/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; @@ -34,9 +34,7 @@ export const SettingsDevelopersApiKeysNew = () => { name: '', }); - const { createOneRecord: createOneApiKey } = useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.ApiKey, - }); + const [createApiKey] = useCreateApiKeyMutation(); const setApiKeyTokenCallback = useRecoilCallback( ({ set }) => @@ -51,11 +49,17 @@ export const SettingsDevelopersApiKeysNew = () => { .plus({ days: formValues.expirationDate ?? 30 }) .toString(); - const newApiKey = await createOneApiKey?.({ - name: formValues.name, - expiresAt, + const { data: newApiKeyData } = await createApiKey({ + variables: { + input: { + name: formValues.name, + expiresAt, + }, + }, }); + const newApiKey = newApiKeyData?.createApiKey; + if (!newApiKey) { return; } @@ -77,7 +81,7 @@ export const SettingsDevelopersApiKeysNew = () => { }); } }; - const canSave = !!formValues.name && createOneApiKey; + const canSave = !!formValues.name && createApiKey; return ( ( - 'FindOneWebhook', - ({ variables: { objectRecordId } }) => { - return HttpResponse.json({ - data: { - webhook: { - __typename: 'Webhook', - id: objectRecordId, - createdAt: '2021-08-27T12:00:00Z', - updatedAt: '2021-08-27T12:00:00Z', - deletedAt: null, - targetUrl: 'https://example.com/webhook', - description: 'A Sample Description', - operations: ['*.created', '*.updated'], - secret: 'sample-secret', - }, - }, - }); - }, - ), graphql.query('FindManyWorkflows', () => { return HttpResponse.json({ data: workflowQueryResult, @@ -711,5 +692,64 @@ export const graphqlMocks = { { status: 200 }, ); }), + metadataGraphql.query('GetApiKeys', () => { + return HttpResponse.json({ + data: { + apiKeys: mockedApiKeys.map((apiKey) => ({ + __typename: 'ApiKey', + ...apiKey, + revokedAt: null, + })), + }, + }); + }), + metadataGraphql.query('GetApiKey', ({ variables }) => { + const apiKeyId = variables.input?.id; + const apiKey = mockedApiKeys.find((key) => key.id === apiKeyId); + + return HttpResponse.json({ + data: { + apiKey: apiKey + ? { + __typename: 'ApiKey', + ...apiKey, + revokedAt: null, + } + : null, + }, + }); + }), + metadataGraphql.query('GetWebhooks', () => { + return HttpResponse.json({ + data: { + webhooks: [ + { + __typename: 'Webhook', + id: '1234', + targetUrl: 'https://api.slackbot.io/webhooks/twenty', + operations: ['*.created', '*.updated'], + description: 'Slack notifications for lead updates', + secret: 'sample-secret', + }, + ], + }, + }); + }), + metadataGraphql.query('GetWebhook', ({ variables }) => { + const webhookId = variables.input?.id; + + return HttpResponse.json({ + data: { + webhook: { + __typename: 'Webhook', + id: webhookId || '1234', + targetUrl: 'https://api.slackbot.io/webhooks/twenty', + operations: ['*.created', '*.updated'], + description: 'Slack notifications for lead updates', + secret: 'sample-secret', + }, + }, + }); + }), ], }; diff --git a/packages/twenty-front/src/testing/mock-data/api-keys.ts b/packages/twenty-front/src/testing/mock-data/api-keys.ts index c9160aa0e..bf13959a9 100644 --- a/packages/twenty-front/src/testing/mock-data/api-keys.ts +++ b/packages/twenty-front/src/testing/mock-data/api-keys.ts @@ -1,4 +1,4 @@ -import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; +import { ApiKey } from '~/generated-metadata/graphql'; type MockedApiKey = Pick< ApiKey, diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index 902f2e226..640cb67a6 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -1,13 +1,13 @@ +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; declare module 'express-serve-static-core' { interface Request { user?: User | null; - apiKey?: ApiKeyWorkspaceEntity | null; + apiKey?: ApiKey | null; userWorkspace?: UserWorkspace; workspace?: Workspace; workspaceId?: string; diff --git a/packages/twenty-server/@types/jest.d.ts b/packages/twenty-server/@types/jest.d.ts index 370218adf..83a2358e7 100644 --- a/packages/twenty-server/@types/jest.d.ts +++ b/packages/twenty-server/@types/jest.d.ts @@ -1,4 +1,5 @@ import 'jest'; +import { DataSource } from 'typeorm'; declare module '@jest/types' { namespace Config { @@ -10,6 +11,7 @@ declare module '@jest/types' { MEMBER_ACCESS_TOKEN: string; GUEST_ACCESS_TOKEN: string; API_KEY_ACCESS_TOKEN: string; + testDataSource?: DataSource; } } } @@ -23,6 +25,7 @@ declare global { const GUEST_ACCESS_TOKEN: string; const API_KEY_ACCESS_TOKEN: string; const WORKSPACE_AGNOSTIC_TOKEN: string; + const testDataSource: DataSource; } export {}; diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 34576bf23..825774586 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command'; -import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command'; import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question'; import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; @@ -15,6 +15,8 @@ import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar- import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module'; +import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command'; + @Module({ imports: [ UpgradeVersionCommandModule, @@ -24,7 +26,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au CalendarEventImportManagerModule, AutomatedTriggerModule, - // Only needed for the data seed command + // Data seeding dependencies TypeORMModule, FieldMetadataModule, ObjectMetadataModule, @@ -32,6 +34,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au WorkspaceManagerModule, DataSourceModule, WorkspaceCacheStorageModule, + ApiKeyModule, ], providers: [ DataSeedWorkspaceCommand, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command.ts new file mode 100644 index 000000000..179601bb4 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command.ts @@ -0,0 +1,212 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; +import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service'; +import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity'; +import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; +import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; + +@Command({ + name: 'upgrade:1-3:migrate-api-keys-webhooks-to-core', + description: + 'Migrate API keys and webhooks from workspace schemas to core schema', +}) +export class MigrateApiKeysWebhooksToCoreCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + @InjectRepository(ApiKey, 'core') + private readonly coreApiKeyRepository: Repository, + @InjectRepository(Webhook, 'core') + private readonly coreWebhookRepository: Repository, + private readonly apiKeyService: ApiKeyService, + private readonly webhookService: WebhookService, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + index, + total, + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + this.logger.log( + `Migrating API keys and webhooks for workspace ${workspaceId} ${index + 1}/${total}`, + ); + + try { + await this.migrateApiKeys(workspaceId, options.dryRun); + + await this.migrateWebhooks(workspaceId, options.dryRun); + + this.logger.log( + `Successfully migrated API keys and webhooks for workspace ${workspaceId}`, + ); + } catch (error) { + this.logger.error( + `Failed to migrate API keys and webhooks for workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + } + + private async migrateApiKeys( + workspaceId: string, + dryRun?: boolean, + ): Promise { + const workspaceApiKeyRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'apiKey', + { shouldBypassPermissionChecks: true }, + ); + + const workspaceApiKeys = await workspaceApiKeyRepository.find({ + withDeleted: true, + }); + + if (workspaceApiKeys.length === 0) { + this.logger.log(`No API keys to migrate for workspace ${workspaceId}`); + + return; + } + + this.logger.log( + `${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceApiKeys.length} API keys to migrate for workspace ${workspaceId}`, + ); + + if (dryRun) { + workspaceApiKeys.forEach((apiKey) => { + const deletedStatus = apiKey.deletedAt ? ' (DELETED)' : ''; + + this.logger.log( + `DRY RUN: Would migrate API key ${apiKey.id} (${apiKey.name})${deletedStatus} from workspace ${workspaceId}`, + ); + }); + + return; + } + + const existingCoreApiKeys = await this.coreApiKeyRepository.find({ + where: { workspaceId }, + select: ['id'], + withDeleted: true, + }); + const existingApiKeyIds = new Set(existingCoreApiKeys.map((ak) => ak.id)); + + for (const workspaceApiKey of workspaceApiKeys) { + if (existingApiKeyIds.has(workspaceApiKey.id)) { + this.logger.warn( + `API key ${workspaceApiKey.id} already exists in core schema for workspace ${workspaceId}, skipping`, + ); + continue; + } + + await this.apiKeyService.create({ + id: workspaceApiKey.id, + name: workspaceApiKey.name, + expiresAt: workspaceApiKey.expiresAt, + revokedAt: workspaceApiKey.revokedAt + ? new Date(workspaceApiKey.revokedAt) + : workspaceApiKey.deletedAt + ? new Date(workspaceApiKey.deletedAt) + : undefined, + workspaceId, + createdAt: new Date(workspaceApiKey.createdAt), + updatedAt: new Date(workspaceApiKey.updatedAt), + }); + + const deletedStatus = workspaceApiKey.deletedAt ? ' (DELETED)' : ''; + + this.logger.log( + `Migrated API key ${workspaceApiKey.id} (${workspaceApiKey.name})${deletedStatus} to core schema`, + ); + } + } + + private async migrateWebhooks( + workspaceId: string, + dryRun?: boolean, + ): Promise { + const workspaceWebhookRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'webhook', + { shouldBypassPermissionChecks: true }, + ); + + const workspaceWebhooks = await workspaceWebhookRepository.find({ + withDeleted: true, + }); + + if (workspaceWebhooks.length === 0) { + this.logger.log(`No webhooks to migrate for workspace ${workspaceId}`); + + return; + } + + this.logger.log( + `${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceWebhooks.length} webhooks to migrate for workspace ${workspaceId}`, + ); + + if (dryRun) { + workspaceWebhooks.forEach((webhook) => { + const deletedStatus = webhook.deletedAt ? ' (DELETED)' : ''; + + this.logger.log( + `DRY RUN: Would migrate webhook ${webhook.id} (${webhook.targetUrl})${deletedStatus} from workspace ${workspaceId}`, + ); + }); + + return; + } + + const existingCoreWebhooks = await this.coreWebhookRepository.find({ + where: { workspaceId }, + select: ['id'], + withDeleted: true, + }); + const existingWebhookIds = new Set(existingCoreWebhooks.map((wh) => wh.id)); + + for (const workspaceWebhook of workspaceWebhooks) { + if (existingWebhookIds.has(workspaceWebhook.id)) { + this.logger.warn( + `Webhook ${workspaceWebhook.id} already exists in core schema for workspace ${workspaceId}, skipping`, + ); + continue; + } + + await this.webhookService.create({ + id: workspaceWebhook.id, + targetUrl: workspaceWebhook.targetUrl, + operations: workspaceWebhook.operations, + description: workspaceWebhook.description, + secret: workspaceWebhook.secret, + workspaceId, + createdAt: new Date(workspaceWebhook.createdAt), + updatedAt: new Date(workspaceWebhook.updatedAt), + deletedAt: workspaceWebhook.deletedAt + ? new Date(workspaceWebhook.deletedAt) + : undefined, + }); + + const deletedStatus = workspaceWebhook.deletedAt ? ' (DELETED)' : ''; + + this.logger.log( + `Migrated webhook ${workspaceWebhook.id} (${workspaceWebhook.targetUrl})${deletedStatus} to core schema`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts new file mode 100644 index 000000000..11ceb3e18 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { MigrateApiKeysWebhooksToCoreCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; +import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity'; +import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace, ApiKey, Webhook], 'core'), + WorkspaceDataSourceModule, + ApiKeyModule, + WebhookModule, + ], + providers: [MigrateApiKeysWebhooksToCoreCommand], + exports: [MigrateApiKeysWebhooksToCoreCommand], +}) +export class V1_3_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts index 16175e72f..e6afb3ee5 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts @@ -5,6 +5,7 @@ import { V0_54_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { V0_55_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/0-55/0-55-upgrade-version-command.module'; import { V1_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-1/1-1-upgrade-version-command.module'; import { V1_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-2/1-2-upgrade-version-command.module'; +import { V1_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module'; import { DatabaseMigrationService, UpgradeCommand, @@ -19,6 +20,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp V0_55_UpgradeVersionCommandModule, V1_1_UpgradeVersionCommandModule, V1_2_UpgradeVersionCommandModule, + V1_3_UpgradeVersionCommandModule, WorkspaceSyncMetadataModule, ], providers: [DatabaseMigrationService, UpgradeCommand], diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 75116b556..8f93b970f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -24,6 +24,7 @@ import { DeduplicateIndexedFieldsCommand } from 'src/database/commands/upgrade-v import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command'; import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command'; import { AddEnqueuedStatusToWorkflowRunCommand } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-enqueued-status-to-workflow-run.command'; +import { MigrateApiKeysWebhooksToCoreCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -147,6 +148,9 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.2 Commands protected readonly migrateWorkflowRunStatesCommand: MigrateWorkflowRunStatesCommand, protected readonly addEnqueuedStatusToWorkflowRunCommand: AddEnqueuedStatusToWorkflowRunCommand, + + // 1.3 Commands + protected readonly migrateApiKeysWebhooksToCoreCommand: MigrateApiKeysWebhooksToCoreCommand, ) { super( workspaceRepository, @@ -200,6 +204,11 @@ export class UpgradeCommand extends UpgradeCommandRunner { afterSyncMetadata: [this.migrateWorkflowRunStatesCommand], }; + const commands_130: VersionCommands = { + beforeSyncMetadata: [this.migrateApiKeysWebhooksToCoreCommand], + afterSyncMetadata: [], + }; + this.allCommands = { '0.53.0': commands_053, '0.54.0': commands_054, @@ -208,6 +217,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { '1.0.0': commands_100, '1.1.0': commands_110, '1.2.0': commands_120, + '1.3.0': commands_130, }; } diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1751690946522-add-api-keys-and-webhook-to-core.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751690946522-add-api-keys-and-webhook-to-core.ts new file mode 100644 index 000000000..41185a7b9 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751690946522-add-api-keys-and-webhook-to-core.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApiKeysAndWebhookToCore1751690946522 + implements MigrationInterface +{ + name = 'AddApiKeysAndWebhookToCore1751690946522'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."apiKey" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "revokedAt" TIMESTAMP WITH TIME ZONE, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_2ae3a5e8e04fb402b2dc8d6ce4b" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_API_KEY_WORKSPACE_ID" ON "core"."apiKey" ("workspaceId") `, + ); + await queryRunner.query( + `CREATE TABLE "core"."webhook" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "targetUrl" character varying NOT NULL, "operations" text array NOT NULL DEFAULT '{*.*}', "description" character varying, "secret" character varying NOT NULL, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_e6765510c2d078db49632b59020" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_WEBHOOK_WORKSPACE_ID" ON "core"."webhook" ("workspaceId") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."apiKey" ADD CONSTRAINT "FK_c8b3efa54a29aa873043e72fb1d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."webhook" ADD CONSTRAINT "FK_597ab5e7de76f1836b8fd80d6b9" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."webhook" DROP CONSTRAINT "FK_597ab5e7de76f1836b8fd80d6b9"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."apiKey" DROP CONSTRAINT "FK_c8b3efa54a29aa873043e72fb1d"`, + ); + await queryRunner.query(`DROP INDEX "core"."IDX_WEBHOOK_WORKSPACE_ID"`); + await queryRunner.query(`DROP TABLE "core"."webhook"`); + await queryRunner.query(`DROP INDEX "core"."IDX_API_KEY_WORKSPACE_ID"`); + await queryRunner.query(`DROP TABLE "core"."apiKey"`); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts index 8e4890121..04e48b8c9 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts @@ -9,7 +9,6 @@ import { BlocklistQueryHookModule } from 'src/modules/blocklist/query-hooks/bloc import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks/calendar-query-hook.module'; import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module'; import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module'; -import { WebhookQueryHookModule } from 'src/modules/webhook/query-hooks/webhook-query-hook.module'; import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module'; @Module({ @@ -18,7 +17,6 @@ import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/que CalendarQueryHookModule, ConnectedAccountQueryHookModule, BlocklistQueryHookModule, - WebhookQueryHookModule, WorkspaceMemberQueryHookModule, DiscoveryModule, ], diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts index 0e412c9bf..6591d0b93 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts @@ -10,9 +10,17 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; +import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects'; +import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; import { CreateManyResolverFactory } from './factories/create-many-resolver.factory'; import { CreateOneResolverFactory } from './factories/create-one-resolver.factory'; @@ -46,6 +54,7 @@ export class WorkspaceResolverFactory { private readonly restoreManyResolverFactory: RestoreManyResolverFactory, private readonly destroyManyResolverFactory: DestroyManyResolverFactory, private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService, + private readonly featureFlagService: FeatureFlagService, ) {} async create( @@ -76,9 +85,44 @@ export class WorkspaceResolverFactory { Mutation: {}, }; + const workspaceId = authContext.workspace?.id; + + if (!workspaceId) { + throw new AuthException( + 'Unauthenticated', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + + const workspaceFeatureFlagsMap = + await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId); + for (const objectMetadata of Object.values(objectMetadataMaps.byId).filter( isDefined, )) { + const workspaceEntity = standardObjectMetadataDefinitions.find( + (entity) => { + const entityMetadata = metadataArgsStorage.filterEntities(entity); + + return entityMetadata?.standardId === objectMetadata.standardId; + }, + ); + + if (workspaceEntity) { + const entityMetadata = + metadataArgsStorage.filterEntities(workspaceEntity); + + if ( + isGatedAndNotEnabled( + entityMetadata?.gate, + workspaceFeatureFlagsMap, + 'graphql', + ) + ) { + continue; + } + } + // Generate query resolvers for (const methodName of workspaceResolverBuilderMethods.queries) { const resolverName = getResolverName(objectMetadata, methodName); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index f15d48dab..47ab6e982 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -9,14 +9,22 @@ import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars- import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories'; import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory'; import { WorkspaceGraphQLSchemaFactory } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { WorkspaceMetadataCacheException, WorkspaceMetadataCacheExceptionCode, } from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; +import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects'; +import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; @Injectable() export class WorkspaceSchemaFactory { @@ -27,6 +35,7 @@ export class WorkspaceSchemaFactory { private readonly workspaceResolverFactory: WorkspaceResolverFactory, private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, + private readonly featureFlagService: FeatureFlagService, ) {} async createGraphQLSchema(authContext: AuthContext): Promise { @@ -57,13 +66,49 @@ export class WorkspaceSchemaFactory { ); } + const workspaceId = authContext.workspace.id; + + if (!workspaceId) { + throw new AuthException( + 'Unauthenticated', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + + const workspaceFeatureFlagsMap = + await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId); + const objectMetadataCollection = Object.values(objectMetadataMaps.byId) .filter(isDefined) .map((objectMetadataItem) => ({ ...objectMetadataItem, fields: Object.values(objectMetadataItem.fieldsById), indexes: objectMetadataItem.indexMetadatas, - })); + })) + .filter((objectMetadata) => { + // Find the corresponding workspace entity for this object metadata + const workspaceEntity = standardObjectMetadataDefinitions.find( + (entity) => { + const entityMetadata = metadataArgsStorage.filterEntities(entity); + + return entityMetadata?.standardId === objectMetadata.standardId; + }, + ); + + if (!workspaceEntity) { + return true; // Include non-workspace entities (custom objects, etc.) + } + + const entityMetadata = + metadataArgsStorage.filterEntities(workspaceEntity); + + // Filter out entities that are GraphQL-gated and not enabled + return !isGatedAndNotEnabled( + entityMetadata?.gate, + workspaceFeatureFlagsMap, + 'graphql', + ); + }); // Get typeDefs from cache let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs( diff --git a/packages/twenty-server/src/engine/core-modules/actor/services/__tests__/created-by-from-auth-context.service.spec.ts b/packages/twenty-server/src/engine/core-modules/actor/services/__tests__/created-by-from-auth-context.service.spec.ts index 77835cc81..c2ab4b02b 100644 --- a/packages/twenty-server/src/engine/core-modules/actor/services/__tests__/created-by-from-auth-context.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/actor/services/__tests__/created-by-from-auth-context.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -12,12 +13,11 @@ import { import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; type TestingAuthContext = Omit & { workspace: Partial; - apiKey?: Partial; + apiKey?: Partial; user?: Partial; }; diff --git a/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts b/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts index 4df7ece99..41201b4e8 100644 --- a/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts +++ b/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts @@ -1,11 +1,11 @@ +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { ActorMetadata, FieldActorSource, } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; -import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; type BuildCreatedByFromApiKeyArgs = { - apiKey: ApiKeyWorkspaceEntity; + apiKey: ApiKey; }; export const buildCreatedByFromApiKey = ({ apiKey, diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.entity.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.entity.ts new file mode 100644 index 000000000..c543e0005 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.entity.ts @@ -0,0 +1,57 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Index('IDX_API_KEY_WORKSPACE_ID', ['workspaceId']) +@Entity({ name: 'apiKey', schema: 'core' }) +@ObjectType('ApiKey') +export class ApiKey { + @IDField(() => UUIDScalarType) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field() + @Column() + name: string; + + @Field(() => Date) + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Field(() => Date, { nullable: true }) + @Column({ type: 'timestamptz', nullable: true }) + revokedAt?: Date | null; + + @Field() + @Column('uuid') + workspaceId: string; + + @Field(() => Date) + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @Field(() => Date) + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @Field(() => Workspace) + @ManyToOne(() => Workspace, (workspace) => workspace.apiKeys, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts new file mode 100644 index 000000000..a017db1b9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.exception.ts @@ -0,0 +1,18 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class ApiKeyException extends CustomException { + declare code: ApiKeyExceptionCode; + constructor( + message: string, + code: ApiKeyExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: string } = {}, + ) { + super(message, code, userFriendlyMessage); + } +} + +export enum ApiKeyExceptionCode { + API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND', + API_KEY_REVOKED = 'API_KEY_REVOKED', + API_KEY_EXPIRED = 'API_KEY_EXPIRED', +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts new file mode 100644 index 000000000..b849aa241 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; +import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver'; +import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApiKey], 'core'), JwtModule], + providers: [ApiKeyService, ApiKeyResolver], + exports: [ApiKeyService, TypeOrmModule], +}) +export class ApiKeyModule {} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts new file mode 100644 index 000000000..bb21c8e19 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts @@ -0,0 +1,82 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { CreateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto'; +import { GetApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto'; +import { RevokeApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto'; +import { UpdateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto'; +import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +import { ApiKey } from './api-key.entity'; +import { ApiKeyService } from './api-key.service'; + +@Resolver(() => ApiKey) +@UseGuards(WorkspaceAuthGuard) +export class ApiKeyResolver { + constructor(private readonly apiKeyService: ApiKeyService) {} + + @Query(() => [ApiKey]) + async apiKeys(@AuthWorkspace() workspace: Workspace): Promise { + return this.apiKeyService.findActiveByWorkspaceId(workspace.id); + } + + @Query(() => ApiKey, { nullable: true }) + async apiKey( + @Args('input') input: GetApiKeyDTO, + @AuthWorkspace() workspace: Workspace, + ): Promise { + try { + const apiKey = await this.apiKeyService.findById(input.id, workspace.id); + + if (!apiKey) { + return null; + } + + return apiKey; + } catch (error) { + apiKeyGraphqlApiExceptionHandler(error); + throw error; + } + } + + @Mutation(() => ApiKey) + async createApiKey( + @AuthWorkspace() workspace: Workspace, + @Args('input') input: CreateApiKeyDTO, + ): Promise { + return this.apiKeyService.create({ + name: input.name, + expiresAt: new Date(input.expiresAt), + revokedAt: input.revokedAt ? new Date(input.revokedAt) : undefined, + workspaceId: workspace.id, + }); + } + + @Mutation(() => ApiKey, { nullable: true }) + async updateApiKey( + @AuthWorkspace() workspace: Workspace, + @Args('input') input: UpdateApiKeyDTO, + ): Promise { + const updateData: Partial = {}; + + if (input.name !== undefined) updateData.name = input.name; + if (input.expiresAt !== undefined) + updateData.expiresAt = new Date(input.expiresAt); + if (input.revokedAt !== undefined) { + updateData.revokedAt = input.revokedAt ? new Date(input.revokedAt) : null; + } + + return this.apiKeyService.update(input.id, workspace.id, updateData); + } + + @Mutation(() => ApiKey, { nullable: true }) + async revokeApiKey( + @AuthWorkspace() workspace: Workspace, + @Args('input') input: RevokeApiKeyDTO, + ): Promise { + return this.apiKeyService.revoke(input.id, workspace.id); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.service.spec.ts new file mode 100644 index 000000000..407e3a987 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.service.spec.ts @@ -0,0 +1,383 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { IsNull } from 'typeorm'; + +import { + ApiKeyException, + ApiKeyExceptionCode, +} from 'src/engine/core-modules/api-key/api-key.exception'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { ApiKey } from './api-key.entity'; +import { ApiKeyService } from './api-key.service'; + +describe('ApiKeyService', () => { + let service: ApiKeyService; + let mockApiKeyRepository: any; + let mockJwtWrapperService: any; + + const mockWorkspaceId = 'workspace-123'; + const mockApiKeyId = 'api-key-456'; + + const mockApiKey: ApiKey = { + id: mockApiKeyId, + name: 'Test API Key', + expiresAt: new Date('2025-12-31'), + revokedAt: undefined, + workspaceId: mockWorkspaceId, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + workspace: {} as any, + }; + + const mockRevokedApiKey: ApiKey = { + ...mockApiKey, + id: 'revoked-api-key', + revokedAt: new Date('2024-06-01'), + }; + + const mockExpiredApiKey: ApiKey = { + ...mockApiKey, + id: 'expired-api-key', + expiresAt: new Date('2024-01-01'), + }; + + beforeEach(async () => { + mockApiKeyRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + }; + + mockJwtWrapperService = { + generateAppSecret: jest.fn(), + sign: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyService, + { + provide: getRepositoryToken(ApiKey, 'core'), + useValue: mockApiKeyRepository, + }, + { + provide: JwtWrapperService, + useValue: mockJwtWrapperService, + }, + ], + }).compile(); + + service = module.get(ApiKeyService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create and save an API key', async () => { + const apiKeyData = { + name: 'New API Key', + expiresAt: new Date('2025-12-31'), + workspaceId: mockWorkspaceId, + }; + + mockApiKeyRepository.create.mockReturnValue(mockApiKey); + mockApiKeyRepository.save.mockResolvedValue(mockApiKey); + + const result = await service.create(apiKeyData); + + expect(mockApiKeyRepository.create).toHaveBeenCalledWith(apiKeyData); + expect(mockApiKeyRepository.save).toHaveBeenCalledWith(mockApiKey); + expect(result).toEqual(mockApiKey); + }); + }); + + describe('findById', () => { + it('should find an API key by ID and workspace ID', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey); + + const result = await service.findById(mockApiKeyId, mockWorkspaceId); + + expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({ + where: { + id: mockApiKeyId, + workspaceId: mockWorkspaceId, + }, + }); + expect(result).toEqual(mockApiKey); + }); + + it('should return null if API key not found', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(null); + + const result = await service.findById('non-existent', mockWorkspaceId); + + expect(result).toBeNull(); + }); + }); + + describe('findByWorkspaceId', () => { + it('should find all API keys for a workspace', async () => { + const mockApiKeys = [mockApiKey, { ...mockApiKey, id: 'another-key' }]; + + mockApiKeyRepository.find.mockResolvedValue(mockApiKeys); + + const result = await service.findByWorkspaceId(mockWorkspaceId); + + expect(mockApiKeyRepository.find).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + }, + }); + expect(result).toEqual(mockApiKeys); + }); + }); + + describe('findActiveByWorkspaceId', () => { + it('should find only active (non-revoked) API keys', async () => { + const activeApiKeys = [mockApiKey]; + + mockApiKeyRepository.find.mockResolvedValue(activeApiKeys); + + const result = await service.findActiveByWorkspaceId(mockWorkspaceId); + + expect(mockApiKeyRepository.find).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + revokedAt: IsNull(), + }, + }); + expect(result).toEqual(activeApiKeys); + }); + }); + + describe('update', () => { + it('should update an existing API key', async () => { + const updateData = { name: 'Updated API Key' }; + const updatedApiKey = { ...mockApiKey, ...updateData }; + + mockApiKeyRepository.findOne + .mockResolvedValueOnce(mockApiKey) + .mockResolvedValueOnce(updatedApiKey); + mockApiKeyRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.update( + mockApiKeyId, + mockWorkspaceId, + updateData, + ); + + expect(mockApiKeyRepository.update).toHaveBeenCalledWith( + mockApiKeyId, + updateData, + ); + expect(result).toEqual(updatedApiKey); + }); + + it('should return null if API key to update does not exist', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(null); + + const result = await service.update('non-existent', mockWorkspaceId, { + name: 'Updated', + }); + + expect(mockApiKeyRepository.update).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe('revoke', () => { + it('should revoke an API key by setting revokedAt', async () => { + const revokedApiKey = { ...mockApiKey, revokedAt: new Date() }; + + mockApiKeyRepository.findOne + .mockResolvedValueOnce(mockApiKey) + .mockResolvedValueOnce(revokedApiKey); + mockApiKeyRepository.update.mockResolvedValue({ affected: 1 }); + + const result = await service.revoke(mockApiKeyId, mockWorkspaceId); + + expect(mockApiKeyRepository.update).toHaveBeenCalledWith( + mockApiKeyId, + expect.objectContaining({ + revokedAt: expect.any(Date), + }), + ); + expect(result).toEqual(revokedApiKey); + }); + }); + + describe('validateApiKey', () => { + it('should validate an active API key', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey); + + const result = await service.validateApiKey( + mockApiKeyId, + mockWorkspaceId, + ); + + expect(result).toEqual(mockApiKey); + }); + + it('should throw ApiKeyException if API key does not exist', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(null); + + await expect( + service.validateApiKey('non-existent', mockWorkspaceId), + ).rejects.toThrow(ApiKeyException); + + await expect( + service.validateApiKey('non-existent', mockWorkspaceId), + ).rejects.toMatchObject({ + code: ApiKeyExceptionCode.API_KEY_NOT_FOUND, + }); + }); + + it('should throw ApiKeyException if API key is revoked', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(mockRevokedApiKey); + + await expect( + service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId), + ).rejects.toThrow(ApiKeyException); + + await expect( + service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId), + ).rejects.toMatchObject({ + code: ApiKeyExceptionCode.API_KEY_REVOKED, + }); + }); + + it('should throw ApiKeyException if API key is expired', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(mockExpiredApiKey); + + await expect( + service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId), + ).rejects.toThrow(ApiKeyException); + + await expect( + service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId), + ).rejects.toMatchObject({ + code: ApiKeyExceptionCode.API_KEY_EXPIRED, + }); + }); + }); + + describe('generateApiKeyToken', () => { + const mockSecret = 'mock-secret'; + const mockToken = 'mock-jwt-token'; + + beforeEach(() => { + mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret); + mockJwtWrapperService.sign.mockReturnValue(mockToken); + }); + + it('should generate a JWT token for a valid API key', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey); + const expiresAt = new Date('2025-12-31'); + + const result = await service.generateApiKeyToken( + mockWorkspaceId, + mockApiKeyId, + expiresAt, + ); + + expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith( + JwtTokenTypeEnum.ACCESS, + mockWorkspaceId, + ); + expect(mockJwtWrapperService.sign).toHaveBeenCalledWith( + { + sub: mockWorkspaceId, + type: JwtTokenTypeEnum.API_KEY, + workspaceId: mockWorkspaceId, + }, + { + secret: mockSecret, + expiresIn: expect.any(Number), + jwtid: mockApiKeyId, + }, + ); + expect(result).toEqual({ token: mockToken }); + }); + + it('should return undefined if no API key ID provided', async () => { + const result = await service.generateApiKeyToken(mockWorkspaceId); + + expect(result).toBeUndefined(); + expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled(); + }); + + it('should use default expiration if no expiresAt provided', async () => { + mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey); + + await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId); + + expect(mockJwtWrapperService.sign).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + expiresIn: '100y', + }), + ); + }); + }); + + describe('utility methods', () => { + describe('isExpired', () => { + it('should return true for expired API key', () => { + const result = service.isExpired(mockExpiredApiKey); + + expect(result).toBe(true); + }); + + it('should return false for non-expired API key', () => { + const result = service.isExpired(mockApiKey); + + expect(result).toBe(false); + }); + }); + + describe('isRevoked', () => { + it('should return true for revoked API key', () => { + const result = service.isRevoked(mockRevokedApiKey); + + expect(result).toBe(true); + }); + + it('should return false for non-revoked API key', () => { + const result = service.isRevoked(mockApiKey); + + expect(result).toBe(false); + }); + }); + + describe('isActive', () => { + it('should return true for active API key', () => { + const result = service.isActive(mockApiKey); + + expect(result).toBe(true); + }); + + it('should return false for revoked API key', () => { + const result = service.isActive(mockRevokedApiKey); + + expect(result).toBe(false); + }); + + it('should return false for expired API key', () => { + const result = service.isActive(mockExpiredApiKey); + + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.service.ts new file mode 100644 index 000000000..f5c3b79e4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.service.ts @@ -0,0 +1,165 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { IsNull, Repository } from 'typeorm'; + +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; +import { + ApiKeyException, + ApiKeyExceptionCode, +} from 'src/engine/core-modules/api-key/api-key.exception'; +import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class ApiKeyService { + constructor( + @InjectRepository(ApiKey, 'core') + private readonly apiKeyRepository: Repository, + private readonly jwtWrapperService: JwtWrapperService, + ) {} + + async create(apiKeyData: Partial): Promise { + const apiKey = this.apiKeyRepository.create(apiKeyData); + + return await this.apiKeyRepository.save(apiKey); + } + + async findById(id: string, workspaceId: string): Promise { + return await this.apiKeyRepository.findOne({ + where: { + id, + workspaceId, + }, + }); + } + + async findByWorkspaceId(workspaceId: string): Promise { + return await this.apiKeyRepository.find({ + where: { + workspaceId, + }, + }); + } + + async findActiveByWorkspaceId(workspaceId: string): Promise { + return await this.apiKeyRepository.find({ + where: { + workspaceId, + revokedAt: IsNull(), + }, + }); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + const apiKey = await this.findById(id, workspaceId); + + if (!apiKey) { + return null; + } + + await this.apiKeyRepository.update(id, updateData); + + return this.findById(id, workspaceId); + } + + async revoke(id: string, workspaceId: string): Promise { + return await this.update(id, workspaceId, { + revokedAt: new Date(), + }); + } + + async validateApiKey(id: string, workspaceId: string): Promise { + const apiKey = await this.findById(id, workspaceId); + + if (!apiKey) { + throw new ApiKeyException( + `API Key with id ${id} not found`, + ApiKeyExceptionCode.API_KEY_NOT_FOUND, + ); + } + + if (apiKey.revokedAt) { + throw new ApiKeyException( + 'This API Key is revoked', + ApiKeyExceptionCode.API_KEY_REVOKED, + { + userFriendlyMessage: + 'This API Key has been revoked and can no longer be used.', + }, + ); + } + + if (new Date() > apiKey.expiresAt) { + throw new ApiKeyException( + 'This API Key has expired', + ApiKeyExceptionCode.API_KEY_EXPIRED, + { + userFriendlyMessage: + 'This API Key has expired. Please create a new one.', + }, + ); + } + + return apiKey; + } + + async generateApiKeyToken( + workspaceId: string, + apiKeyId?: string, + expiresAt?: Date | string, + ): Promise | undefined> { + if (!apiKeyId) { + return; + } + + await this.validateApiKey(apiKeyId, workspaceId); + + const secret = this.jwtWrapperService.generateAppSecret( + JwtTokenTypeEnum.ACCESS, + workspaceId, + ); + + let expiresIn: string | number; + + if (expiresAt) { + expiresIn = Math.floor( + (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, + ); + } else { + expiresIn = '100y'; + } + + const token = this.jwtWrapperService.sign( + { + sub: workspaceId, + type: JwtTokenTypeEnum.API_KEY, + workspaceId, + }, + { + secret, + expiresIn, + jwtid: apiKeyId, + }, + ); + + return { token }; + } + + isExpired(apiKey: ApiKey): boolean { + return new Date() > apiKey.expiresAt; + } + + isRevoked(apiKey: ApiKey): boolean { + return !!apiKey.revokedAt; + } + + isActive(apiKey: ApiKey): boolean { + return !this.isRevoked(apiKey) && !this.isExpired(apiKey); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/dtos/create-api-key.dto.ts b/packages/twenty-server/src/engine/core-modules/api-key/dtos/create-api-key.dto.ts new file mode 100644 index 000000000..943f0d647 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/dtos/create-api-key.dto.ts @@ -0,0 +1,25 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsDateString, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; + +@InputType() +export class CreateApiKeyDTO { + @Field() + @IsNotEmpty() + @IsString() + name: string; + + @Field() + @IsDateString() + expiresAt: string; + + @Field({ nullable: true }) + @IsOptional() + @IsDateString() + revokedAt?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/dtos/get-api-key.dto.ts b/packages/twenty-server/src/engine/core-modules/api-key/dtos/get-api-key.dto.ts new file mode 100644 index 000000000..9fb93aac5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/dtos/get-api-key.dto.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@InputType() +export class GetApiKeyDTO { + @Field() + @IsNotEmpty() + @IsString() + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/dtos/revoke-api-key.dto.ts b/packages/twenty-server/src/engine/core-modules/api-key/dtos/revoke-api-key.dto.ts new file mode 100644 index 000000000..6e05277b5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/dtos/revoke-api-key.dto.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@InputType() +export class RevokeApiKeyDTO { + @Field() + @IsNotEmpty() + @IsString() + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/dtos/update-api-key.dto.ts b/packages/twenty-server/src/engine/core-modules/api-key/dtos/update-api-key.dto.ts new file mode 100644 index 000000000..9be6b9668 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/dtos/update-api-key.dto.ts @@ -0,0 +1,31 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsDateString, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; + +@InputType() +export class UpdateApiKeyDTO { + @Field() + @IsNotEmpty() + @IsString() + id: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + name?: string; + + @Field({ nullable: true }) + @IsDateString() + @IsOptional() + expiresAt?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsDateString() + revokedAt?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts new file mode 100644 index 000000000..f1197d96f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util.ts @@ -0,0 +1,33 @@ +import { + ApiKeyException, + ApiKeyExceptionCode, +} from 'src/engine/core-modules/api-key/api-key.exception'; +import { + ForbiddenError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const apiKeyGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof ApiKeyException) { + switch (error.code) { + case ApiKeyExceptionCode.API_KEY_NOT_FOUND: + throw new NotFoundError(error.message); + case ApiKeyExceptionCode.API_KEY_REVOKED: + throw new ForbiddenError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + case ApiKeyExceptionCode.API_KEY_EXPIRED: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + throw error; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index b6b264f5a..f64c183b1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -4,6 +4,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; @@ -79,6 +80,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; Workspace, User, AppToken, + ApiKey, FeatureFlag, WorkspaceSSOIdentityProvider, KeyValuePair, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index ea680a2f5..7347ee102 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -3,235 +3,263 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { JwtAuthStrategy } from './jwt.auth.strategy'; describe('JwtAuthStrategy', () => { let strategy: JwtAuthStrategy; - let workspaceRepository: any; let userWorkspaceRepository: any; let userRepository: any; - let twentyORMGlobalManager: any; + let apiKeyRepository: any; + let jwtWrapperService: any; + const jwt = { sub: 'sub-default', jti: 'jti-default', }; - workspaceRepository = { - findOneBy: jest.fn(async () => new Workspace()), - }; - - userRepository = { - findOne: jest.fn(async () => null), - }; - - userWorkspaceRepository = { - findOne: jest.fn(async () => new UserWorkspace()), - }; - - const jwtWrapperService: any = { - extractJwtFromRequest: jest.fn(() => () => 'token'), - }; - - twentyORMGlobalManager = { - getRepositoryForWorkspace: jest.fn(async () => ({ - findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })), - })), - }; - - // first we test the API_KEY case - it('should throw AuthException if type is API_KEY and workspace is not found', async () => { - const payload = { - ...jwt, - type: 'API_KEY', - }; - + beforeEach(() => { workspaceRepository = { - findOneBy: jest.fn(async () => null), - }; - - strategy = new JwtAuthStrategy( - jwtWrapperService, - twentyORMGlobalManager, - workspaceRepository, - {} as any, - userWorkspaceRepository, - ); - - await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( - new AuthException( - 'Workspace not found', - AuthExceptionCode.WORKSPACE_NOT_FOUND, - ), - ); - }); - - it('should throw AuthExceptionCode if type is API_KEY not found', async () => { - const payload = { - ...jwt, - type: 'API_KEY', - }; - - workspaceRepository = { - findOneBy: jest.fn(async () => new Workspace()), - }; - - twentyORMGlobalManager = { - getRepositoryForWorkspace: jest.fn(async () => ({ - findOne: jest.fn(async () => null), - })), - }; - - strategy = new JwtAuthStrategy( - jwtWrapperService, - twentyORMGlobalManager, - workspaceRepository, - {} as any, - userWorkspaceRepository, - ); - - await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( - new AuthException( - 'This API Key is revoked', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ), - ); - }); - - it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => { - const payload = { - ...jwt, - type: 'API_KEY', - }; - - workspaceRepository = { - findOneBy: jest.fn(async () => new Workspace()), - }; - - twentyORMGlobalManager = { - getRepositoryForWorkspace: jest.fn(async () => ({ - findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })), - })), - }; - - strategy = new JwtAuthStrategy( - jwtWrapperService, - twentyORMGlobalManager, - workspaceRepository, - {} as any, - userWorkspaceRepository, - ); - - const result = await strategy.validate(payload as JwtPayload); - - expect(result).toBeTruthy(); - expect(result.apiKey?.id).toBe('api-key-id'); - }); - - // second we test the ACCESS cases - it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => { - const payload = { - sub: 'sub-default', - type: 'ACCESS', - }; - - workspaceRepository = { - findOneBy: jest.fn(async () => new Workspace()), + findOneBy: jest.fn(), }; userRepository = { - findOne: jest.fn(async () => null), - }; - - strategy = new JwtAuthStrategy( - jwtWrapperService, - twentyORMGlobalManager, - workspaceRepository, - userRepository, - userWorkspaceRepository, - ); - - await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( - new AuthException('UserWorkspace not found', expect.any(String)), - ); - try { - await strategy.validate(payload as JwtPayload); - } catch (e) { - expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND); - } - }); - - it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => { - const payload = { - sub: 'sub-default', - type: 'ACCESS', - }; - - workspaceRepository = { - findOneBy: jest.fn(async () => new Workspace()), - }; - - userRepository = { - findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })), + findOne: jest.fn(), }; userWorkspaceRepository = { - findOne: jest.fn(async () => null), + findOne: jest.fn(), }; - strategy = new JwtAuthStrategy( - jwtWrapperService, - twentyORMGlobalManager, - workspaceRepository, - userRepository, - userWorkspaceRepository, - ); + apiKeyRepository = { + findOne: jest.fn(), + }; - await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( - new AuthException('UserWorkspace not found', expect.any(String)), - ); - try { - await strategy.validate(payload as JwtPayload); - } catch (e) { - expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND); - } + jwtWrapperService = { + extractJwtFromRequest: jest.fn(() => () => 'token'), + }; }); - it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => { - const payload = { - sub: 'sub-default', - type: 'ACCESS', - userWorkspaceId: 'userWorkspaceId', - }; + afterEach(() => { + jest.clearAllMocks(); + }); - workspaceRepository = { - findOneBy: jest.fn(async () => new Workspace()), - }; + describe('API_KEY validation', () => { + it('should throw AuthException if type is API_KEY and workspace is not found', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; - userRepository = { - findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })), - }; + workspaceRepository.findOneBy.mockResolvedValue(null); - userWorkspaceRepository = { - findOne: jest.fn(async () => ({ + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ), + ); + }); + + it('should throw AuthExceptionCode if type is API_KEY not found', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; + + const mockWorkspace = new Workspace(); + + mockWorkspace.id = 'workspace-id'; + workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace); + + apiKeyRepository.findOne.mockResolvedValue(null); + + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException( + 'This API Key is revoked', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ), + ); + }); + + it('should throw AuthExceptionCode if API_KEY is revoked', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; + + const mockWorkspace = new Workspace(); + + mockWorkspace.id = 'workspace-id'; + workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace); + + apiKeyRepository.findOne.mockResolvedValue({ + id: 'api-key-id', + revokedAt: new Date(), + }); + + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException( + 'This API Key is revoked', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ), + ); + }); + + it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; + + const mockWorkspace = new Workspace(); + + mockWorkspace.id = 'workspace-id'; + workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace); + + apiKeyRepository.findOne.mockResolvedValue({ + id: 'api-key-id', + revokedAt: null, + }); + + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); + + const result = await strategy.validate(payload as JwtPayload); + + expect(result).toBeTruthy(); + expect(result.apiKey?.id).toBe('api-key-id'); + + expect(apiKeyRepository.findOne).toHaveBeenCalledWith({ + where: { + id: payload.jti, + workspaceId: mockWorkspace.id, + }, + }); + }); + }); + + describe('ACCESS token validation', () => { + it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => { + const payload = { + sub: 'sub-default', + type: 'ACCESS', + userWorkspaceId: 'userWorkspaceId', + }; + + workspaceRepository.findOneBy.mockResolvedValue(new Workspace()); + + userRepository.findOne.mockResolvedValue(null); + + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException('UserWorkspace not found', expect.any(String)), + ); + + try { + await strategy.validate(payload as JwtPayload); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND); + } + }); + + it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => { + const payload = { + sub: 'sub-default', + type: 'ACCESS', + userWorkspaceId: 'userWorkspaceId', + }; + + workspaceRepository.findOneBy.mockResolvedValue(new Workspace()); + + userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' }); + + userWorkspaceRepository.findOne.mockResolvedValue(null); + + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException('UserWorkspace not found', expect.any(String)), + ); + + try { + await strategy.validate(payload as JwtPayload); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND); + } + }); + + it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => { + const payload = { + sub: 'sub-default', + type: 'ACCESS', + userWorkspaceId: 'userWorkspaceId', + }; + + workspaceRepository.findOneBy.mockResolvedValue(new Workspace()); + + userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' }); + + userWorkspaceRepository.findOne.mockResolvedValue({ id: 'userWorkspaceId', - })), - }; + }); - strategy = new JwtAuthStrategy( - jwtWrapperService, - twentyORMGlobalManager, - workspaceRepository, - userRepository, - userWorkspaceRepository, - ); + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + ); - const user = await strategy.validate(payload as JwtPayload); + const user = await strategy.validate(payload as JwtPayload); - expect(user.user?.lastName).toBe('lastNameDefault'); - expect(user.userWorkspaceId).toBe('userWorkspaceId'); + expect(user.user?.lastName).toBe('lastNameDefault'); + expect(user.userWorkspaceId).toBe('userWorkspaceId'); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index a8ece6d9b..98c5e53f7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Strategy } from 'passport-jwt'; import { Repository } from 'typeorm'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { AuthException, AuthExceptionCode, @@ -24,20 +25,18 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; - @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( private readonly jwtWrapperService: JwtWrapperService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, + @InjectRepository(ApiKey, 'core') + private readonly apiKeyRepository: Repository, ) { const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest(); // @ts-expect-error legacy noImplicitAny @@ -87,15 +86,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ), ); - const apiKeyRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspace.id, - 'apiKey', - ); - - const apiKey = await apiKeyRepository.findOne({ + const apiKey = await this.apiKeyRepository.findOne({ where: { id: payload.jti, + workspaceId: workspace.id, }, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 0ae60002f..fe610a5f0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; @@ -21,7 +22,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s imports: [ JwtModule, TypeOrmModule.forFeature( - [User, AppToken, Workspace, UserWorkspace], + [User, AppToken, Workspace, UserWorkspace, ApiKey], 'core', ), TypeORMModule, diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 06e77277f..68acb75a3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -1,12 +1,12 @@ +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; export type AuthContext = { user?: User | null | undefined; - apiKey?: ApiKeyWorkspaceEntity | null | undefined; + apiKey?: ApiKey | null | undefined; workspaceMemberId?: string; workspace?: Workspace; userWorkspaceId?: string; 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 18a6975d3..bc1409a68 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 @@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module'; import { AiModule } from 'src/engine/core-modules/ai/ai.module'; import { aiModuleFactory } from 'src/engine/core-modules/ai/ai.module-factory'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module'; import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; @@ -42,6 +43,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 { 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'; @@ -116,6 +118,8 @@ import { FileModule } from './file/file.module'; inject: [TwentyConfigService, FileStorageService], }), SearchModule, + ApiKeyModule, + WebhookModule, ], exports: [ AuditModule, diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 11ac737d5..099bd2ab7 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -8,5 +8,7 @@ export enum FeatureFlagKey { IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', + IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', + IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', } 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 new file mode 100644 index 000000000..57374afb2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/dtos/create-webhook.dto.ts @@ -0,0 +1,20 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsUrl } from 'class-validator'; + +@InputType() +export class CreateWebhookDTO { + @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 new file mode 100644 index 000000000..78d316095 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/dtos/delete-webhook.dto.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@InputType() +export class DeleteWebhookDTO { + @Field() + @IsNotEmpty() + @IsString() + 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 new file mode 100644 index 000000000..57902cd98 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/dtos/get-webhook.dto.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@InputType() +export class GetWebhookDTO { + @Field() + @IsNotEmpty() + @IsString() + 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 new file mode 100644 index 000000000..e8084fe62 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/dtos/update-webhook.dto.ts @@ -0,0 +1,23 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@InputType() +export class UpdateWebhookDTO { + @Field() + @IsNotEmpty() + @IsString() + 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/utils/webhook-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts new file mode 100644 index 000000000..cbd46f100 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util.ts @@ -0,0 +1,28 @@ +import { + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + WebhookException, + WebhookExceptionCode, +} from 'src/engine/core-modules/webhook/webhook.exception'; + +export const webhookGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof WebhookException) { + switch (error.code) { + case WebhookExceptionCode.WEBHOOK_NOT_FOUND: + throw new NotFoundError(error.message); + case WebhookExceptionCode.INVALID_TARGET_URL: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + throw error; +}; 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 new file mode 100644 index 000000000..5853f3f0d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/webhook.entity.ts @@ -0,0 +1,66 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Index('IDX_WEBHOOK_WORKSPACE_ID', ['workspaceId']) +@Entity({ name: 'webhook', schema: 'core' }) +@ObjectType('Webhook') +export class Webhook { + @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() + @Column('uuid') + workspaceId: string; + + @Field() + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @Field() + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @Field({ nullable: true }) + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; + + @Field(() => Workspace) + @ManyToOne(() => Workspace, (workspace) => workspace.webhooks, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; +} diff --git a/packages/twenty-server/src/engine/core-modules/webhook/webhook.exception.ts b/packages/twenty-server/src/engine/core-modules/webhook/webhook.exception.ts new file mode 100644 index 000000000..bd49220ef --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/webhook.exception.ts @@ -0,0 +1,17 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class WebhookException extends CustomException { + declare code: WebhookExceptionCode; + constructor( + message: string, + code: WebhookExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: string } = {}, + ) { + super(message, code, userFriendlyMessage); + } +} + +export enum WebhookExceptionCode { + WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND', + INVALID_TARGET_URL = 'INVALID_TARGET_URL', +} 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 new file mode 100644 index 000000000..2dfa413aa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/webhook.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Webhook } from './webhook.entity'; +import { WebhookResolver } from './webhook.resolver'; +import { WebhookService } from './webhook.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Webhook], 'core')], + providers: [WebhookService, WebhookResolver], + 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 new file mode 100644 index 000000000..f169e703f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/webhook.resolver.ts @@ -0,0 +1,88 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto'; +import { DeleteWebhookDTO } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto'; +import { GetWebhookDTO } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto'; +import { UpdateWebhookDTO } 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +import { Webhook } from './webhook.entity'; +import { WebhookService } from './webhook.service'; + +@Resolver(() => Webhook) +@UseGuards(WorkspaceAuthGuard) +export class WebhookResolver { + constructor(private readonly webhookService: WebhookService) {} + + @Query(() => [Webhook]) + async webhooks(@AuthWorkspace() workspace: Workspace): Promise { + return this.webhookService.findByWorkspaceId(workspace.id); + } + + @Query(() => Webhook, { nullable: true }) + async webhook( + @Args('input') input: GetWebhookDTO, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.webhookService.findById(input.id, workspace.id); + } + + @Mutation(() => Webhook) + async createWebhook( + @AuthWorkspace() workspace: Workspace, + @Args('input') input: CreateWebhookDTO, + ): 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(() => Webhook, { nullable: true }) + async updateWebhook( + @AuthWorkspace() workspace: Workspace, + @Args('input') input: UpdateWebhookDTO, + ): Promise { + try { + const updateData: Partial = {}; + + 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: DeleteWebhookDTO, + @AuthWorkspace() workspace: Workspace, + ): 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 new file mode 100644 index 000000000..82de24993 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.spec.ts @@ -0,0 +1,420 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ArrayContains, IsNull } from 'typeorm'; + +import { Webhook } 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: Webhook = { + 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(Webhook, 'core'), + 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 new file mode 100644 index 000000000..9f90e43e1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/webhook/webhook.service.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { ArrayContains, IsNull, Repository } from 'typeorm'; + +import { Webhook } from './webhook.entity'; +import { WebhookException, WebhookExceptionCode } from './webhook.exception'; + +@Injectable() +export class WebhookService { + constructor( + @InjectRepository(Webhook, 'core') + 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: '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: Partial, + ): Promise { + const webhook = await this.findById(id, workspaceId); + + if (!webhook) { + return null; + } + + if (isDefined(updateData.targetUrl)) { + const normalizedTargetUrl = this.normalizeTargetUrl(updateData.targetUrl); + + if (!this.validateTargetUrl(normalizedTargetUrl)) { + throw new WebhookException( + 'Invalid target URL provided', + WebhookExceptionCode.INVALID_TARGET_URL, + { userFriendlyMessage: '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 1569c10e2..d758261a1 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 @@ -15,6 +15,7 @@ import { } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -22,6 +23,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; @@ -127,6 +129,12 @@ export class Workspace { }) agents: Relation; + @OneToMany(() => Webhook, (webhook) => webhook.workspace) + webhooks: Relation; + + @OneToMany(() => ApiKey, (apiKey) => apiKey.workspace) + apiKeys: Relation; + @Field() @Column({ default: 1 }) metadataVersion: number; diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts index 64478b58b..2a757df96 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts @@ -4,6 +4,8 @@ import { TypedReflect } from 'src/utils/typed-reflect'; export interface WorkspaceGateOptions { featureFlag: string; + excludeFromDatabase?: boolean; + excludeFromGraphQL?: boolean; } export function WorkspaceGate(options: WorkspaceGateOptions) { @@ -16,19 +18,25 @@ export function WorkspaceGate(options: WorkspaceGateOptions) { ); } + const gateOptions = { + featureFlag: options.featureFlag, + excludeFromDatabase: options.excludeFromDatabase ?? true, + excludeFromGraphQL: options.excludeFromGraphQL ?? true, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (target: any, propertyKey?: string | symbol) => { if (propertyKey !== undefined) { TypedReflect.defineMetadata( 'workspace:gate-metadata-args', - options, + gateOptions, target, propertyKey.toString(), ); } else { TypedReflect.defineMetadata( 'workspace:gate-metadata-args', - options, + gateOptions, target, ); } diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts index e88d9fd0a..9581a2413 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/gate.interface.ts @@ -1,3 +1,5 @@ export interface Gate { featureFlag: string; + excludeFromDatabase?: boolean; + excludeFromGraphQL?: boolean; } diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts new file mode 100644 index 000000000..9c53dfb49 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts @@ -0,0 +1,29 @@ +import { DataSource } from 'typeorm'; + +const tableName = 'apiKey'; + +export const seedApiKeys = async ( + dataSource: DataSource, + schemaName: string, + workspaceId: string, +) => { + await dataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.${tableName}`, [ + 'id', + 'name', + 'expiresAt', + 'workspaceId', + ]) + .orIgnore() + .values([ + { + id: '20202020-f401-4d8a-a731-64d007c27bad', + name: 'My api key', + expiresAt: '2025-12-31T23:59:59.000Z', + workspaceId: workspaceId, + }, + ]) + .execute(); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts index 0374093c6..ced97451f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm'; import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util'; +import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util'; import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util'; import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; @@ -32,6 +33,8 @@ export const seedCoreSchema = async ({ await seedUsers(dataSource, schemaName); await seedUserWorkspaces(dataSource, schemaName, workspaceId); + await seedApiKeys(dataSource, schemaName, workspaceId); + if (shouldSeedFeatureFlags) { await seedFeatureFlags(dataSource, schemaName, workspaceId); } diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant.ts index 4ceb24b48..8c62691b4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant.ts @@ -19,7 +19,7 @@ export const API_KEY_DATA_SEEDS: ApiKeyDataSeed[] = [ id: API_KEY_DATA_SEED_IDS.ID_1, name: 'My api key', expiresAt: new Date( - new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // In 100 years + new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // 100 years from now ), }, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts index 2b6c052a2..ad182fd4f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts @@ -5,10 +5,6 @@ import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/wor import { computeTableName } from 'src/engine/utils/compute-table-name.util'; import { shouldSeedWorkspaceFavorite } from 'src/engine/utils/should-seed-workspace-favorite'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; -import { - API_KEY_DATA_SEED_COLUMNS, - API_KEY_DATA_SEEDS, -} from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant'; import { CALENDAR_CHANNEL_DATA_SEED_COLUMNS, CALENDAR_CHANNEL_DATA_SEEDS, @@ -130,11 +126,6 @@ const RECORD_SEEDS_CONFIGS = [ pgColumns: OPPORTUNITY_DATA_SEED_COLUMNS, recordSeeds: OPPORTUNITY_DATA_SEEDS, }, - { - tableName: 'apiKey', - pgColumns: API_KEY_DATA_SEED_COLUMNS, - recordSeeds: API_KEY_DATA_SEEDS, - }, { tableName: 'connectedAccount', pgColumns: CONNECTED_ACCOUNT_DATA_SEED_COLUMNS, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts index 70c1f5df1..06332755b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory.ts @@ -49,6 +49,7 @@ export class StandardFieldFactory { isGatedAndNotEnabled( workspaceEntityMetadataArgs.gate, context.featureFlags, + 'database', ) ) { return acc; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory.ts index 8c72d2df0..efce23d2f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory.ts @@ -37,6 +37,7 @@ export class StandardObjectFactory { isGatedAndNotEnabled( workspaceEntityMetadataArgs.gate, context.featureFlags, + 'database', ) ) { return undefined; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index 97abd2d71..cb6e807f0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -37,7 +37,6 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta // TODO: Maybe we should automate this with the DiscoverService of Nest.JS export const standardObjectMetadataDefinitions = [ - ApiKeyWorkspaceEntity, AttachmentWorkspaceEntity, BlocklistWorkspaceEntity, CalendarEventWorkspaceEntity, @@ -55,7 +54,6 @@ export const standardObjectMetadataDefinitions = [ ViewFilterGroupWorkspaceEntity, ViewSortWorkspaceEntity, ViewWorkspaceEntity, - WebhookWorkspaceEntity, WorkflowWorkspaceEntity, WorkflowVersionWorkspaceEntity, WorkflowRunWorkspaceEntity, @@ -73,4 +71,6 @@ export const standardObjectMetadataDefinitions = [ PersonWorkspaceEntity, TaskWorkspaceEntity, TaskTargetWorkspaceEntity, + ApiKeyWorkspaceEntity, + WebhookWorkspaceEntity, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts index fa6912031..442224611 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts @@ -1,11 +1,33 @@ import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; +export type GateContext = 'database' | 'graphql'; + export const isGatedAndNotEnabled = ( gate: Gate | undefined, workspaceFeatureFlagsMap: Record, + context?: GateContext, ): boolean => { - const featureFlagValue = - gate?.featureFlag && workspaceFeatureFlagsMap[gate.featureFlag]; + // If no gate, not gated + if (!gate?.featureFlag) { + return false; + } - return gate?.featureFlag !== undefined && !featureFlagValue; + // Check if explicitly excluded from the specific context + switch (context) { + case 'database': + if (gate.excludeFromDatabase === false) { + return false; // Not gated for database + } + break; + case 'graphql': + if (gate.excludeFromGraphQL === false) { + return false; // Not gated for GraphQL + } + break; + } + + // If context-specific exclusion is true or undefined (default behavior), check the flag + const featureFlagValue = workspaceFeatureFlagsMap[gate.featureFlag]; + + return !featureFlagValue; }; diff --git a/packages/twenty-server/src/modules/api-key/standard-objects/api-key.workspace-entity.ts b/packages/twenty-server/src/modules/api-key/standard-objects/api-key.workspace-entity.ts index a1010382c..5578c823b 100644 --- a/packages/twenty-server/src/modules/api-key/standard-objects/api-key.workspace-entity.ts +++ b/packages/twenty-server/src/modules/api-key/standard-objects/api-key.workspace-entity.ts @@ -4,6 +4,7 @@ import { FieldMetadataType } from 'twenty-shared/types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { API_KEY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; @@ -20,6 +21,11 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync labelIdentifierStandardId: API_KEY_STANDARD_FIELD_IDS.name, }) @WorkspaceIsSystem() +@WorkspaceGate({ + featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', + excludeFromDatabase: false, + excludeFromGraphQL: true, +}) export class ApiKeyWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ standardId: API_KEY_STANDARD_FIELD_IDS.name, diff --git a/packages/twenty-server/src/modules/webhook/jobs/call-webhook-jobs.job.ts b/packages/twenty-server/src/modules/webhook/jobs/call-webhook-jobs.job.ts index 9a47408fa..9e40ca9ce 100644 --- a/packages/twenty-server/src/modules/webhook/jobs/call-webhook-jobs.job.ts +++ b/packages/twenty-server/src/modules/webhook/jobs/call-webhook-jobs.job.ts @@ -1,28 +1,26 @@ import { isDefined } from 'twenty-shared/utils'; -import { ArrayContains } from 'typeorm'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; import { CallWebhookJob, CallWebhookJobData, } from 'src/modules/webhook/jobs/call-webhook.job'; -import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; -import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record'; import { ObjectRecordEventForWebhook } from 'src/modules/webhook/types/object-record-event-for-webhook.type'; +import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record'; @Processor(MessageQueue.webhookQueue) export class CallWebhookJobsJob { constructor( @InjectMessageQueue(MessageQueue.webhookQueue) private readonly messageQueueService: MessageQueueService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly webhookService: WebhookService, ) {} @Process(CallWebhookJobsJob.name) @@ -34,22 +32,17 @@ export class CallWebhookJobsJob { // Also change the openApi schema for webhooks // packages/twenty-server/src/engine/core-modules/open-api/utils/computeWebhooks.utils.ts - const webhookRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceEventBatch.workspaceId, - 'webhook', - ); - const [nameSingular, operation] = workspaceEventBatch.name.split('.'); - const webhooks = await webhookRepository.find({ - where: [ - { operations: ArrayContains([`${nameSingular}.${operation}`]) }, - { operations: ArrayContains([`*.${operation}`]) }, - { operations: ArrayContains([`${nameSingular}.*`]) }, - { operations: ArrayContains(['*.*']) }, + const webhooks = await this.webhookService.findByOperations( + workspaceEventBatch.workspaceId, + [ + `${nameSingular}.${operation}`, + `*.${operation}`, + `${nameSingular}.*`, + '*.*', ], - }); + ); for (const eventData of workspaceEventBatch.events) { const eventName = workspaceEventBatch.name; diff --git a/packages/twenty-server/src/modules/webhook/jobs/webhook-job.module.ts b/packages/twenty-server/src/modules/webhook/jobs/webhook-job.module.ts index 03c789f42..b3a5704e0 100644 --- a/packages/twenty-server/src/modules/webhook/jobs/webhook-job.module.ts +++ b/packages/twenty-server/src/modules/webhook/jobs/webhook-job.module.ts @@ -2,11 +2,12 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; +import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module'; import { CallWebhookJobsJob } from 'src/modules/webhook/jobs/call-webhook-jobs.job'; import { CallWebhookJob } from 'src/modules/webhook/jobs/call-webhook.job'; @Module({ - imports: [HttpModule, AuditModule], + imports: [HttpModule, AuditModule, WebhookModule], providers: [CallWebhookJobsJob, CallWebhookJob], }) export class WebhookJobModule {} diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/__tests__/webhook-url-validation.service.spec.ts b/packages/twenty-server/src/modules/webhook/query-hooks/__tests__/webhook-url-validation.service.spec.ts deleted file mode 100644 index 70f24835b..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/__tests__/webhook-url-validation.service.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; -import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service'; - -describe('WebhookUrlValidationService', () => { - let service: WebhookUrlValidationService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WebhookUrlValidationService], - }).compile(); - - service = module.get( - WebhookUrlValidationService, - ); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('validateWebhookUrl', () => { - it('should accept valid HTTP URLs', () => { - expect(() => { - service.validateWebhookUrl('http://example.com/webhook'); - }).not.toThrow(); - }); - - it('should accept valid HTTPS URLs', () => { - expect(() => { - service.validateWebhookUrl('https://example.com/webhook'); - }).not.toThrow(); - }); - - it('should accept URLs with ports', () => { - expect(() => { - service.validateWebhookUrl('http://localhost:3000/webhook'); - }).not.toThrow(); - }); - - it('should accept URLs with paths and query parameters', () => { - expect(() => { - service.validateWebhookUrl( - 'https://api.example.com/webhooks/receive?token=abc123', - ); - }).not.toThrow(); - }); - - it('should reject URLs without scheme', () => { - expect(() => { - service.validateWebhookUrl('example.com/webhook'); - }).toThrow(GraphqlQueryRunnerException); - }); - - it('should reject malformed URLs', () => { - expect(() => { - service.validateWebhookUrl('not-a-url'); - }).toThrow(GraphqlQueryRunnerException); - }); - - it('should reject URLs with FTP scheme', () => { - expect(() => { - service.validateWebhookUrl('ftp://example.com/webhook'); - }).toThrow(GraphqlQueryRunnerException); - }); - - it('should reject URLs with mailto scheme', () => { - expect(() => { - service.validateWebhookUrl('mailto:user@example.com'); - }).toThrow(GraphqlQueryRunnerException); - }); - - it('should reject URLs with custom schemes', () => { - expect(() => { - service.validateWebhookUrl('custom://example.com/webhook'); - }).toThrow(GraphqlQueryRunnerException); - }); - - it('should provide helpful error message for malformed URLs', () => { - try { - service.validateWebhookUrl('example.com/webhook'); - fail('Expected exception to be thrown'); - } catch (error) { - expect(error).toBeInstanceOf(GraphqlQueryRunnerException); - expect(error.code).toBe( - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - expect(error.message).toContain('Invalid URL: missing scheme'); - expect(error.message).toContain('example.com/webhook'); - } - }); - - it('should provide helpful error message for invalid scheme', () => { - try { - service.validateWebhookUrl('ftp://example.com/webhook'); - fail('Expected exception to be thrown'); - } catch (error) { - expect(error).toBeInstanceOf(GraphqlQueryRunnerException); - expect(error.code).toBe( - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - expect(error.message).toContain('Only HTTP and HTTPS are allowed'); - expect(error.message).toContain('ftp:'); - } - }); - - it('should reject empty strings', () => { - expect(() => { - service.validateWebhookUrl(''); - }).toThrow(GraphqlQueryRunnerException); - }); - }); -}); diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-create-many.pre-query.hook.ts b/packages/twenty-server/src/modules/webhook/query-hooks/webhook-create-many.pre-query.hook.ts deleted file mode 100644 index 7f8aa2822..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-create-many.pre-query.hook.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; -import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; - -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; -import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; - -@WorkspaceQueryHook(`webhook.createMany`) -export class WebhookCreateManyPreQueryHook - implements WorkspacePreQueryHookInstance -{ - async execute(): Promise> { - throw new GraphqlQueryRunnerException( - 'Method not allowed.', - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } -} diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-create-one.pre-query.hook.ts b/packages/twenty-server/src/modules/webhook/query-hooks/webhook-create-one.pre-query.hook.ts deleted file mode 100644 index 5a9aabbbb..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-create-one.pre-query.hook.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; -import { CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; - -import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service'; -import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; - -@WorkspaceQueryHook(`webhook.createOne`) -export class WebhookCreateOnePreQueryHook - implements WorkspacePreQueryHookInstance -{ - constructor( - private readonly webhookUrlValidationService: WebhookUrlValidationService, - ) {} - - async execute( - _authContext: AuthContext, - _objectName: string, - payload: CreateOneResolverArgs, - ): Promise> { - this.webhookUrlValidationService.validateWebhookUrl(payload.data.targetUrl); - - return payload; - } -} diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-query-hook.module.ts b/packages/twenty-server/src/modules/webhook/query-hooks/webhook-query-hook.module.ts deleted file mode 100644 index bf3a2369f..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-query-hook.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { WebhookCreateManyPreQueryHook } from 'src/modules/webhook/query-hooks/webhook-create-many.pre-query.hook'; -import { WebhookCreateOnePreQueryHook } from 'src/modules/webhook/query-hooks/webhook-create-one.pre-query.hook'; -import { WebhookUpdateManyPreQueryHook } from 'src/modules/webhook/query-hooks/webhook-update-many.pre-query.hook'; -import { WebhookUpdateOnePreQueryHook } from 'src/modules/webhook/query-hooks/webhook-update-one.pre-query.hook'; -import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service'; - -@Module({ - providers: [ - WebhookUrlValidationService, - WebhookCreateOnePreQueryHook, - WebhookCreateManyPreQueryHook, - WebhookUpdateOnePreQueryHook, - WebhookUpdateManyPreQueryHook, - ], -}) -export class WebhookQueryHookModule {} diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-update-many.pre-query.hook.ts b/packages/twenty-server/src/modules/webhook/query-hooks/webhook-update-many.pre-query.hook.ts deleted file mode 100644 index 006425499..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-update-many.pre-query.hook.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; -import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; - -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; -import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; - -@WorkspaceQueryHook(`webhook.updateMany`) -export class WebhookUpdateManyPreQueryHook - implements WorkspacePreQueryHookInstance -{ - async execute(): Promise> { - throw new GraphqlQueryRunnerException( - 'Method not allowed.', - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } -} diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/webhook/query-hooks/webhook-update-one.pre-query.hook.ts deleted file mode 100644 index ced73b5ca..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-update-one.pre-query.hook.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; -import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; - -import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service'; -import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; - -@WorkspaceQueryHook(`webhook.updateOne`) -export class WebhookUpdateOnePreQueryHook - implements WorkspacePreQueryHookInstance -{ - constructor( - private readonly webhookUrlValidationService: WebhookUrlValidationService, - ) {} - - async execute( - _authContext: AuthContext, - _objectName: string, - payload: UpdateOneResolverArgs, - ): Promise> { - if (payload.data.targetUrl) { - this.webhookUrlValidationService.validateWebhookUrl( - payload.data.targetUrl, - ); - } - - return payload; - } -} diff --git a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-url-validation.service.ts b/packages/twenty-server/src/modules/webhook/query-hooks/webhook-url-validation.service.ts deleted file mode 100644 index b1f99b479..000000000 --- a/packages/twenty-server/src/modules/webhook/query-hooks/webhook-url-validation.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; - -@Injectable() -export class WebhookUrlValidationService { - validateWebhookUrl(targetUrl: string): void { - let parsedUrl: URL; - - try { - parsedUrl = new URL(targetUrl); - } catch { - throw new GraphqlQueryRunnerException( - `Invalid URL: missing scheme. URLs must include http:// or https://. Received: ${targetUrl}`, - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } - - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { - throw new GraphqlQueryRunnerException( - `Invalid URL scheme. Only HTTP and HTTPS are allowed. Received: ${parsedUrl.protocol}`, - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } - } -} diff --git a/packages/twenty-server/src/modules/webhook/standard-objects/webhook.workspace-entity.ts b/packages/twenty-server/src/modules/webhook/standard-objects/webhook.workspace-entity.ts index b39ab4989..796dcd0d5 100644 --- a/packages/twenty-server/src/modules/webhook/standard-objects/webhook.workspace-entity.ts +++ b/packages/twenty-server/src/modules/webhook/standard-objects/webhook.workspace-entity.ts @@ -4,6 +4,7 @@ import { FieldMetadataType } from 'twenty-shared/types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; @@ -21,6 +22,11 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync labelIdentifierStandardId: WEBHOOK_STANDARD_FIELD_IDS.targetUrl, }) @WorkspaceIsSystem() +@WorkspaceGate({ + featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', + excludeFromDatabase: false, + excludeFromGraphQL: true, +}) export class WebhookWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ standardId: WEBHOOK_STANDARD_FIELD_IDS.targetUrl, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts index 8649005b0..885006244 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsException, @@ -10,14 +10,10 @@ import { PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; -import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; @Injectable() export class WorkspaceMemberPreQueryHookService { - constructor( - private readonly permissionsService: PermissionsService, - private readonly featureFlagService: FeatureFlagService, - ) {} + constructor(private readonly permissionsService: PermissionsService) {} async validateWorkspaceMemberUpdatePermissionOrThrow({ userWorkspaceId, @@ -30,7 +26,7 @@ export class WorkspaceMemberPreQueryHookService { workspaceMemberId?: string; targettedWorkspaceMemberId?: string; workspaceId: string; - apiKey?: ApiKeyWorkspaceEntity | null; + apiKey?: ApiKey | null; }) { if (isDefined(apiKey)) { return; diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/api-keys.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/api-keys.integration-spec.ts deleted file mode 100644 index 3eeadba42..000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/api-keys.integration-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('apiKeysResolver (e2e)', () => { - it('should find many apiKeys', () => { - const queryData = { - query: ` - query apiKeys { - apiKeys { - edges { - node { - name - expiresAt - revokedAt - id - createdAt - updatedAt - deletedAt - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.apiKeys; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const apiKeys = edges[0].node; - - expect(apiKeys).toHaveProperty('name'); - expect(apiKeys).toHaveProperty('expiresAt'); - expect(apiKeys).toHaveProperty('revokedAt'); - expect(apiKeys).toHaveProperty('id'); - expect(apiKeys).toHaveProperty('createdAt'); - expect(apiKeys).toHaveProperty('updatedAt'); - expect(apiKeys).toHaveProperty('deletedAt'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/webhooks.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/webhooks.integration-spec.ts deleted file mode 100644 index 2fc55eea6..000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/webhooks.integration-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import request from 'supertest'; - -const client = request(`http://localhost:${APP_PORT}`); - -describe('webhooksResolver (e2e)', () => { - it('should find many webhooks', () => { - const queryData = { - query: ` - query webhooks { - webhooks { - edges { - node { - id - targetUrl - operations - description - createdAt - updatedAt - deletedAt - } - } - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.webhooks; - - expect(data).toBeDefined(); - expect(Array.isArray(data.edges)).toBe(true); - - const edges = data.edges; - - if (edges.length > 0) { - const webhooks = edges[0].node; - - expect(webhooks).toHaveProperty('id'); - expect(webhooks).toHaveProperty('targetUrl'); - expect(webhooks).toHaveProperty('operations'); - expect(webhooks).toHaveProperty('description'); - expect(webhooks).toHaveProperty('createdAt'); - expect(webhooks).toHaveProperty('updatedAt'); - expect(webhooks).toHaveProperty('deletedAt'); - } - }); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts index 28faebc70..94d2c77d7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts @@ -1,6 +1,5 @@ import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields'; import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; -import { TEST_API_KEY_1_ID } from 'test/integration/constants/test-api-key-ids.constant'; import { TEST_PERSON_1_ID, TEST_PERSON_2_ID, @@ -31,14 +30,6 @@ describe('SearchResolver', () => { { id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } }, ]; - const [apiKey] = [ - { - id: TEST_API_KEY_1_ID, - name: 'record not searchable', - expiresAt: new Date(Date.now()), - }, - ]; - const [firstPet, secondPet] = [ { id: TEST_PET_ID_1, name: 'searchInput1' }, { id: TEST_PET_ID_2, name: 'searchInput2' }, @@ -68,13 +59,6 @@ describe('SearchResolver', () => { secondPerson, thirdPerson, ]); - - await performCreateManyOperation( - 'apiKey', - 'apiKeys', - OBJECT_MODEL_COMMON_FIELDS, - [apiKey], - ); } catch (error) { // eslint-disable-next-line no-console console.log(error); diff --git a/packages/twenty-server/test/integration/metadata/suites/developers/api-keys.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/developers/api-keys.integration-spec.ts new file mode 100644 index 000000000..5e0f86c0a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/developers/api-keys.integration-spec.ts @@ -0,0 +1,273 @@ +import { gql } from 'graphql-tag'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +describe('apiKeysResolver (e2e)', () => { + let createdApiKeyId: string | undefined; + + afterEach(async () => { + if (createdApiKeyId) { + await testDataSource + .query('DELETE FROM core."apiKey" WHERE id = $1', [createdApiKeyId]) + .catch(() => {}); + createdApiKeyId = undefined; + } + }); + + describe('apiKeys query', () => { + it('should find many API keys', async () => { + const response = await makeMetadataAPIRequest({ + query: gql` + query GetApiKeys { + apiKeys { + id + name + expiresAt + revokedAt + } + } + `, + }); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.apiKeys).toBeDefined(); + expect(Array.isArray(response.body.data.apiKeys)).toBe(true); + }); + }); + + describe('createApiKey mutation', () => { + it('should create an API key successfully', async () => { + const apiKeyInput = { + name: 'Test API Key', + expiresAt: '2025-12-31T23:59:59Z', + }; + + const response = await makeMetadataAPIRequest({ + query: gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: apiKeyInput, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const createdApiKey = response.body.data.createApiKey; + + expect(createdApiKey).toBeDefined(); + expect(createdApiKey.id).toBeDefined(); + expect(createdApiKey.name).toBe(apiKeyInput.name); + expect(createdApiKey.expiresAt).toBe('2025-12-31T23:59:59.000Z'); + expect(createdApiKey.revokedAt).toBeNull(); + + createdApiKeyId = createdApiKey.id; + }); + + it('should fail to create API key with invalid expiry date', async () => { + const apiKeyInput = { + name: 'Test API Key', + expiresAt: 'invalid-date', + }; + + const response = await makeMetadataAPIRequest({ + query: gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: apiKeyInput, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); + }); + }); + + describe('updateApiKey mutation', () => { + it('should update an API key successfully', async () => { + const createResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: { + name: 'Test API Key', + expiresAt: '2025-12-31T23:59:59Z', + }, + }, + }); + + const createdApiKey = createResponse.body.data.createApiKey; + + createdApiKeyId = createdApiKey.id; + + const updateInput = { + id: createdApiKey.id, + name: 'Updated API Key', + expiresAt: '2026-01-01T00:00:00Z', + }; + + const updateResponse = await makeMetadataAPIRequest({ + query: gql` + mutation UpdateApiKey($input: UpdateApiKeyDTO!) { + updateApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: updateInput, + }, + }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.data).toBeDefined(); + expect(updateResponse.body.errors).toBeUndefined(); + + const updatedApiKey = updateResponse.body.data.updateApiKey; + + expect(updatedApiKey.id).toBe(createdApiKey.id); + expect(updatedApiKey.name).toBe(updateInput.name); + expect(updatedApiKey.expiresAt).toBe('2026-01-01T00:00:00.000Z'); + expect(updatedApiKey.revokedAt).toBeNull(); + }); + }); + + describe('apiKey query', () => { + it('should find a specific API key', async () => { + const createResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: { + name: 'Test API Key', + expiresAt: '2025-12-31T23:59:59Z', + }, + }, + }); + + const createdApiKey = createResponse.body.data.createApiKey; + + createdApiKeyId = createdApiKey.id; + + const queryResponse = await makeMetadataAPIRequest({ + query: gql` + query GetApiKey($input: GetApiKeyDTO!) { + apiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: { id: createdApiKey.id }, + }, + }); + + expect(queryResponse.status).toBe(200); + expect(queryResponse.body.data).toBeDefined(); + expect(queryResponse.body.errors).toBeUndefined(); + + const apiKey = queryResponse.body.data.apiKey; + + expect(apiKey).toBeDefined(); + expect(apiKey.id).toBe(createdApiKey.id); + expect(apiKey.name).toBe(createdApiKey.name); + expect(apiKey.expiresAt).toBe(createdApiKey.expiresAt); + expect(apiKey.revokedAt).toBeNull(); + }); + }); + + describe('revokeApiKey mutation', () => { + it('should revoke an API key successfully', async () => { + const createResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateApiKey($input: CreateApiKeyDTO!) { + createApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: { + name: 'Test API Key', + expiresAt: '2025-12-31T23:59:59Z', + }, + }, + }); + + const createdApiKey = createResponse.body.data.createApiKey; + + createdApiKeyId = createdApiKey.id; + + const revokeResponse = await makeMetadataAPIRequest({ + query: gql` + mutation RevokeApiKey($input: RevokeApiKeyDTO!) { + revokeApiKey(input: $input) { + id + name + expiresAt + revokedAt + } + } + `, + variables: { + input: { id: createdApiKey.id }, + }, + }); + + expect(revokeResponse.status).toBe(200); + expect(revokeResponse.body.data).toBeDefined(); + expect(revokeResponse.body.errors).toBeUndefined(); + + const revokedApiKey = revokeResponse.body.data.revokeApiKey; + + expect(revokedApiKey.id).toBe(createdApiKey.id); + expect(revokedApiKey.name).toBe(createdApiKey.name); + expect(revokedApiKey.expiresAt).toBe(createdApiKey.expiresAt); + expect(revokedApiKey.revokedAt).not.toBeNull(); + }); + }); +}); 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 new file mode 100644 index 000000000..90cf6b525 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts @@ -0,0 +1,311 @@ +import { gql } from 'graphql-tag'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +describe('webhooksResolver (e2e)', () => { + let createdWebhookId: string | undefined; + + afterEach(async () => { + if (createdWebhookId) { + await makeMetadataAPIRequest({ + query: gql` + mutation DeleteWebhook($input: DeleteWebhookDTO!) { + deleteWebhook(input: $input) + } + `, + variables: { + input: { id: createdWebhookId }, + }, + }).catch(() => {}); + createdWebhookId = undefined; + } + }); + + describe('webhooks query', () => { + it('should find many webhooks', async () => { + const response = await makeMetadataAPIRequest({ + query: gql` + query GetWebhooks { + webhooks { + id + targetUrl + operations + description + secret + } + } + `, + }); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.webhooks).toBeDefined(); + expect(Array.isArray(response.body.data.webhooks)).toBe(true); + }); + }); + + describe('createWebhook mutation', () => { + it('should create a webhook successfully', async () => { + const webhookInput = { + targetUrl: 'https://example.com/webhook', + operations: ['person.created', 'company.updated'], + description: 'Test webhook', + secret: 'test-secret', + }; + + const response = await makeMetadataAPIRequest({ + query: gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: webhookInput, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const createdWebhook = response.body.data.createWebhook; + + expect(createdWebhook).toBeDefined(); + expect(createdWebhook.id).toBeDefined(); + expect(createdWebhook.targetUrl).toBe(webhookInput.targetUrl); + expect(createdWebhook.operations).toEqual(webhookInput.operations); + expect(createdWebhook.description).toBe(webhookInput.description); + expect(createdWebhook.secret).toBe(webhookInput.secret); + + createdWebhookId = createdWebhook.id; + }); + + it('should fail to create webhook with invalid URL', async () => { + const webhookInput = { + targetUrl: 'invalid-url', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }; + + const response = await makeMetadataAPIRequest({ + query: gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: webhookInput, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); + }); + }); + + describe('updateWebhook mutation', () => { + it('should update a webhook successfully', async () => { + const createResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: { + targetUrl: 'https://example.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }, + }, + }); + + const createdWebhook = createResponse.body.data.createWebhook; + + createdWebhookId = createdWebhook.id; + + const updateInput = { + id: createdWebhook.id, + targetUrl: 'https://updated.com/webhook', + operations: ['person.updated', 'company.created'], + description: 'Updated webhook', + secret: 'updated-secret', + }; + + const updateResponse = await makeMetadataAPIRequest({ + query: gql` + mutation UpdateWebhook($input: UpdateWebhookDTO!) { + updateWebhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: updateInput, + }, + }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.data).toBeDefined(); + expect(updateResponse.body.errors).toBeUndefined(); + + const updatedWebhook = updateResponse.body.data.updateWebhook; + + expect(updatedWebhook.id).toBe(createdWebhook.id); + expect(updatedWebhook.targetUrl).toBe(updateInput.targetUrl); + expect(updatedWebhook.operations).toEqual(updateInput.operations); + expect(updatedWebhook.description).toBe(updateInput.description); + expect(updatedWebhook.secret).toBe(updateInput.secret); + }); + }); + + describe('webhook query', () => { + it('should find a specific webhook', async () => { + const createResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: { + targetUrl: 'https://example.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }, + }, + }); + + const createdWebhook = createResponse.body.data.createWebhook; + + createdWebhookId = createdWebhook.id; + + const queryResponse = await makeMetadataAPIRequest({ + query: gql` + query GetWebhook($input: GetWebhookDTO!) { + webhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: { id: createdWebhook.id }, + }, + }); + + expect(queryResponse.status).toBe(200); + expect(queryResponse.body.data).toBeDefined(); + expect(queryResponse.body.errors).toBeUndefined(); + + const webhook = queryResponse.body.data.webhook; + + expect(webhook).toBeDefined(); + expect(webhook.id).toBe(createdWebhook.id); + expect(webhook.targetUrl).toBe(createdWebhook.targetUrl); + expect(webhook.operations).toEqual(createdWebhook.operations); + expect(webhook.description).toBe(createdWebhook.description); + expect(webhook.secret).toBe(createdWebhook.secret); + }); + }); + + describe('deleteWebhook mutation', () => { + it('should delete a webhook successfully', async () => { + const createResponse = await makeMetadataAPIRequest({ + query: gql` + mutation CreateWebhook($input: CreateWebhookDTO!) { + createWebhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: { + targetUrl: 'https://example.com/webhook', + operations: ['person.created'], + description: 'Test webhook', + secret: 'test-secret', + }, + }, + }); + + const createdWebhook = createResponse.body.data.createWebhook; + + const deleteResponse = await makeMetadataAPIRequest({ + query: gql` + mutation DeleteWebhook($input: DeleteWebhookDTO!) { + deleteWebhook(input: $input) + } + `, + variables: { + input: { id: createdWebhook.id }, + }, + }); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.data).toBeDefined(); + expect(deleteResponse.body.errors).toBeUndefined(); + + const queryResponse = await makeMetadataAPIRequest({ + query: gql` + query GetWebhook($input: GetWebhookDTO!) { + webhook(input: $input) { + id + targetUrl + operations + description + secret + } + } + `, + variables: { + input: { id: createdWebhook.id }, + }, + }); + + expect(queryResponse.status).toBe(200); + expect(queryResponse.body.data.webhook).toBeNull(); + + createdWebhookId = undefined; + }); + }); +});