Api keys and webhook migration to core (#13011)

TODO: check Zapier trigger records work as expected

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
nitin
2025-07-09 20:33:54 +05:30
committed by GitHub
parent 18792f9f74
commit 484c267aa6
113 changed files with 4563 additions and 1060 deletions
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
};
@@ -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<Workspace>;
}
@@ -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',
}
@@ -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 {}
@@ -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<Webhook[]> {
return this.webhookService.findByWorkspaceId(workspace.id);
}
@Query(() => Webhook, { nullable: true })
async webhook(
@Args('input') input: GetWebhookDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<Webhook | null> {
return this.webhookService.findById(input.id, workspace.id);
}
@Mutation(() => Webhook)
async createWebhook(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: CreateWebhookDTO,
): Promise<Webhook> {
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<Webhook | null> {
try {
const updateData: Partial<Webhook> = {};
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<boolean> {
const result = await this.webhookService.delete(input.id, workspace.id);
return result !== null;
}
}
@@ -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>(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: [],
});
});
});
});
@@ -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<Webhook>,
) {}
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<Webhook[]> {
return this.webhookRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
});
}
async findByOperations(
workspaceId: string,
operations: string[],
): Promise<Webhook[]> {
return this.webhookRepository.find({
where: operations.map((operation) => ({
workspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
}
async findById(id: string, workspaceId: string): Promise<Webhook | null> {
const webhook = await this.webhookRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
return webhook || null;
}
async create(webhookData: Partial<Webhook>): Promise<Webhook> {
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<Webhook>,
): Promise<Webhook | null> {
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<Webhook | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
await this.webhookRepository.softDelete(id);
return webhook;
}
}