refactor: Webhooks (#12487)

Closes #12303

### What’s Changed
- Replace auto‐save with explicit Save / Cancel
Webhook forms now use manual “Save” and “Cancel” buttons instead of the
old debounced auto‐save/update.

- Separate “New” and “Detail” routes
Two dedicated paths `/settings/webhooks/new` for creation and
/`settings/webhooks/:webhookId` for editing, making the UX clearer.

- URL hint & normalization
If a user omits the http(s):// scheme, we display a “Will be saved as
https://…” hint and automatically default to HTTPS.

- Centralized validation with Zod
Introduced a `webhookFormSchema` for client‐side URL, operations, and
secret validation.

- Storybook coverage
Added stories for both “New Webhook” and “Webhook Detail”

- Unit tests
Added tests for the new `useWebhookForm` hook
This commit is contained in:
nitin
2025-06-13 11:07:25 +05:30
committed by GitHub
parent b160871227
commit 3d57c90e04
89 changed files with 3465 additions and 1679 deletions

View File

@ -3,6 +3,8 @@ import { Logger } from '@nestjs/common';
import crypto from 'crypto';
import { getAbsoluteUrl } from 'twenty-shared/utils';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { WEBHOOK_RESPONSE_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
@ -72,7 +74,7 @@ export class CallWebhookJob {
}
const response = await this.httpService.axiosRef.post(
data.targetUrl,
getAbsoluteUrl(data.targetUrl),
payloadWithoutSecret,
{ headers },
);

View File

@ -0,0 +1,117 @@
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>(
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);
});
});
});

View File

@ -0,0 +1,21 @@
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<CreateManyResolverArgs<WebhookWorkspaceEntity>> {
throw new GraphqlQueryRunnerException(
'Method not allowed.',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}

View File

@ -0,0 +1,26 @@
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<WebhookWorkspaceEntity>,
): Promise<CreateOneResolverArgs<WebhookWorkspaceEntity>> {
this.webhookUrlValidationService.validateWebhookUrl(payload.data.targetUrl);
return payload;
}
}

View File

@ -0,0 +1,18 @@
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 {}

View File

@ -0,0 +1,21 @@
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<UpdateManyResolverArgs<WebhookWorkspaceEntity>> {
throw new GraphqlQueryRunnerException(
'Method not allowed.',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}

View File

@ -0,0 +1,30 @@
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<WebhookWorkspaceEntity>,
): Promise<UpdateOneResolverArgs<WebhookWorkspaceEntity>> {
if (payload.data.targetUrl) {
this.webhookUrlValidationService.validateWebhookUrl(
payload.data.targetUrl,
);
}
return payload;
}
}

View File

@ -0,0 +1,29 @@
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,
);
}
}
}