diff --git a/src/webhooks/webhook.dto.ts b/src/webhooks/webhook.dto.ts new file mode 100644 index 00000000..cc98d8ed --- /dev/null +++ b/src/webhooks/webhook.dto.ts @@ -0,0 +1,50 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, + IsUrl, +} from 'class-validator'; + +export enum WebhookEventType { + PROPERTY_CREATED = 'PROPERTY_CREATED', + PROPERTY_UPDATED = 'PROPERTY_UPDATED', + PROPERTY_STATUS_CHANGED = 'PROPERTY_STATUS_CHANGED', + TRANSACTION_CREATED = 'TRANSACTION_CREATED', + TRANSACTION_UPDATED = 'TRANSACTION_UPDATED', + TRANSACTION_COMPLETED = 'TRANSACTION_COMPLETED', + USER_VERIFIED = 'USER_VERIFIED', +} + +export class CreateWebhookDto { + @IsUrl() + url: string; + + @IsArray() + @IsEnum(WebhookEventType, { each: true }) + eventTypes: WebhookEventType[]; + + @IsOptional() + @IsString() + description?: string; +} + +export class UpdateWebhookDto { + @IsOptional() + @IsUrl() + url?: string; + + @IsOptional() + @IsArray() + @IsEnum(WebhookEventType, { each: true }) + eventTypes?: WebhookEventType[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/src/webhooks/webhooks.controller.ts b/src/webhooks/webhooks.controller.ts new file mode 100644 index 00000000..18e1f885 --- /dev/null +++ b/src/webhooks/webhooks.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { WebhooksService } from './webhooks.service'; +import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; + +@UseGuards(JwtAuthGuard) +@Controller('webhooks') +export class WebhooksController { + constructor(private readonly webhooksService: WebhooksService) {} + + @Post() + create(@CurrentUser() user: any, @Body() dto: CreateWebhookDto) { + return this.webhooksService.create(user.id, dto); + } + + @Get() + findAll(@CurrentUser() user: any) { + return this.webhooksService.findAll(user.id); + } + + @Get(':id') + findOne(@Param('id') id: string, @CurrentUser() user: any) { + return this.webhooksService.findOne(id, user.id); + } + + @Patch(':id') + update( + @Param('id') id: string, + @CurrentUser() user: any, + @Body() dto: UpdateWebhookDto, + ) { + return this.webhooksService.update(id, user.id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string, @CurrentUser() user: any) { + return this.webhooksService.remove(id, user.id); + } + + @Get(':id/deliveries') + getDeliveries(@Param('id') id: string, @CurrentUser() user: any) { + return this.webhooksService.getDeliveries(id, user.id); + } +} \ No newline at end of file diff --git a/src/webhooks/webhooks.module.ts b/src/webhooks/webhooks.module.ts new file mode 100644 index 00000000..4fd43919 --- /dev/null +++ b/src/webhooks/webhooks.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { WebhooksController } from './webhooks.controller'; +import { WebhooksService } from './webhooks.service'; +import { PrismaModule } from '../database/prisma.module'; +import { ScheduleModule } from '@nestjs/schedule'; + +@Module({ + imports: [PrismaModule, ScheduleModule.forRoot()], + controllers: [WebhooksController], + providers: [WebhooksService], + exports: [WebhooksService], +}) +export class WebhooksModule {} \ No newline at end of file diff --git a/src/webhooks/webhooks.service.spec.ts b/src/webhooks/webhooks.service.spec.ts new file mode 100644 index 00000000..996b435e --- /dev/null +++ b/src/webhooks/webhooks.service.spec.ts @@ -0,0 +1,143 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebhooksService } from './webhooks.service'; +import { PrismaService } from '../database/prisma.service'; +import { NotFoundException } from '@nestjs/common'; +import { WebhookEventType, WebhookDeliveryStatus } from '@prisma/client'; + +const mockPrisma = { + webhook: { + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + webhookDelivery: { + create: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, +}; + +describe('WebhooksService', () => { + let service: WebhooksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhooksService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(WebhooksService); + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a webhook with a generated secret', async () => { + const dto = { url: 'https://example.com/hook', eventTypes: [WebhookEventType.PROPERTY_CREATED] }; + mockPrisma.webhook.create.mockResolvedValue({ id: '1', ...dto, secret: 'abc', isActive: true }); + const result = await service.create('user-1', dto as any); + expect(mockPrisma.webhook.create).toHaveBeenCalledTimes(1); + const callArgs = mockPrisma.webhook.create.mock.calls[0][0]; + expect(callArgs.data.secret).toBeDefined(); + expect(callArgs.data.secret).toHaveLength(64); + expect(result).toBeDefined(); + }); + }); + + describe('findAll', () => { + it('should return all webhooks for a user', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([{ id: '1' }, { id: '2' }]); + const result = await service.findAll('user-1'); + expect(result).toHaveLength(2); + expect(mockPrisma.webhook.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: 'user-1' } }), + ); + }); + }); + + describe('findOne', () => { + it('should return a webhook if found', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue({ id: '1' }); + const result = await service.findOne('1', 'user-1'); + expect(result).toEqual({ id: '1' }); + }); + + it('should throw NotFoundException if not found', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue(null); + await expect(service.findOne('bad-id', 'user-1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('should update a webhook', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue({ id: '1' }); + mockPrisma.webhook.update.mockResolvedValue({ id: '1', isActive: false }); + const result = await service.update('1', 'user-1', { isActive: false }); + expect(mockPrisma.webhook.update).toHaveBeenCalledTimes(1); + expect(result.isActive).toBe(false); + }); + }); + + describe('remove', () => { + it('should delete a webhook', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue({ id: '1' }); + mockPrisma.webhook.delete.mockResolvedValue({ id: '1' }); + const result = await service.remove('1', 'user-1'); + expect(result).toEqual({ message: 'Webhook deleted successfully' }); + }); + }); + + describe('trigger', () => { + it('should create a delivery for each matching active webhook', async () => { + mockPrisma.webhook.findMany.mockResolvedValue([ + { id: 'wh-1', url: 'https://example.com', secret: 'sec', eventTypes: [WebhookEventType.PROPERTY_CREATED] }, + ]); + mockPrisma.webhookDelivery.create.mockResolvedValue({ id: 'del-1' }); + mockPrisma.webhookDelivery.update.mockResolvedValue({}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'ok', + }); + + await service.trigger(WebhookEventType.PROPERTY_CREATED, { event: 'PROPERTY_CREATED', data: {} }); + expect(mockPrisma.webhookDelivery.create).toHaveBeenCalledTimes(1); + }); + }); + + describe('getDeliveries', () => { + it('should return deliveries for a webhook', async () => { + mockPrisma.webhook.findFirst.mockResolvedValue({ id: 'wh-1' }); + mockPrisma.webhookDelivery.findMany.mockResolvedValue([{ id: 'del-1', status: WebhookDeliveryStatus.SUCCESS }]); + const result = await service.getDeliveries('wh-1', 'user-1'); + expect(result).toHaveLength(1); + }); + }); + + describe('retryFailedDeliveries', () => { + it('should retry due failed deliveries', async () => { + mockPrisma.webhookDelivery.findMany.mockResolvedValue([ + { + id: 'del-1', + attempts: 1, + payload: { event: 'PROPERTY_CREATED' }, + webhook: { id: 'wh-1', url: 'https://example.com', secret: 'sec' }, + }, + ]); + mockPrisma.webhookDelivery.update.mockResolvedValue({}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'ok', + }); + + await service.retryFailedDeliveries(); + expect(mockPrisma.webhookDelivery.update).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/webhooks/webhooks.service.ts b/src/webhooks/webhooks.service.ts new file mode 100644 index 00000000..4ff8ee87 --- /dev/null +++ b/src/webhooks/webhooks.service.ts @@ -0,0 +1,230 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'; +import { WebhookEventType, WebhookDeliveryStatus } from '@prisma/client'; +import * as crypto from 'crypto'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +@Injectable() +export class WebhooksService { + private readonly logger = new Logger(WebhooksService.name); + private readonly MAX_ATTEMPTS = 3; + private readonly RETRY_DELAYS = [60, 300, 900]; // seconds: 1min, 5min, 15min + + constructor(private readonly prisma: PrismaService) {} + + // ── Registration ──────────────────────────────────────────────────────────── + + async create(userId: string, dto: CreateWebhookDto) { + const secret = crypto.randomBytes(32).toString('hex'); + return this.prisma.webhook.create({ + data: { + userId, + url: dto.url, + secret, + eventTypes: dto.eventTypes as WebhookEventType[], + description: dto.description, + }, + select: { + id: true, + url: true, + eventTypes: true, + description: true, + isActive: true, + secret: true, + createdAt: true, + }, + }); + } + + async findAll(userId: string) { + return this.prisma.webhook.findMany({ + where: { userId }, + select: { + id: true, + url: true, + eventTypes: true, + description: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + async findOne(id: string, userId: string) { + const webhook = await this.prisma.webhook.findFirst({ + where: { id, userId }, + select: { + id: true, + url: true, + eventTypes: true, + description: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!webhook) throw new NotFoundException('Webhook not found'); + return webhook; + } + + async update(id: string, userId: string, dto: UpdateWebhookDto) { + await this.findOne(id, userId); + return this.prisma.webhook.update({ + where: { id }, + data: { + ...(dto.url && { url: dto.url }), + ...(dto.eventTypes && { + eventTypes: dto.eventTypes as WebhookEventType[], + }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + ...(dto.description !== undefined && { description: dto.description }), + }, + select: { + id: true, + url: true, + eventTypes: true, + description: true, + isActive: true, + updatedAt: true, + }, + }); + } + + async remove(id: string, userId: string) { + await this.findOne(id, userId); + await this.prisma.webhook.delete({ where: { id } }); + return { message: 'Webhook deleted successfully' }; + } + + // ── Delivery ───────────────────────────────────────────────────────────────── + + async trigger(eventType: WebhookEventType, payload: object) { + const webhooks = await this.prisma.webhook.findMany({ + where: { + isActive: true, + eventTypes: { has: eventType }, + }, + }); + + for (const webhook of webhooks) { + const delivery = await this.prisma.webhookDelivery.create({ + data: { + webhookId: webhook.id, + eventType, + payload, + status: WebhookDeliveryStatus.PENDING, + }, + }); + await this.deliver(webhook, delivery.id, payload); + } + } + + private async deliver(webhook: any, deliveryId: string, payload: object) { + const body = JSON.stringify(payload); + const signature = this.sign(body, webhook.secret); + + try { + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PropChain-Signature': signature, + 'X-PropChain-Event': (payload as any)['event'] ?? 'webhook', + }, + signal: AbortSignal.timeout(10_000), + body, + }); + + const responseBody = await response.text().catch(() => ''); + + await this.prisma.webhookDelivery.update({ + where: { id: deliveryId }, + data: { + status: response.ok + ? WebhookDeliveryStatus.SUCCESS + : WebhookDeliveryStatus.FAILED, + attempts: { increment: 1 }, + responseStatus: response.status, + responseBody: responseBody.slice(0, 1000), + deliveredAt: response.ok ? new Date() : null, + nextRetryAt: !response.ok ? this.nextRetry(1) : null, + }, + }); + + this.logger.log( + `Webhook ${webhook.id} delivery ${response.ok ? 'succeeded' : 'failed'} (${response.status})`, + ); + } catch (err) { + await this.prisma.webhookDelivery.update({ + where: { id: deliveryId }, + data: { + status: WebhookDeliveryStatus.FAILED, + attempts: { increment: 1 }, + errorMessage: err.message, + nextRetryAt: this.nextRetry(1), + }, + }); + this.logger.warn(`Webhook ${webhook.id} delivery error: ${err.message}`); + } + } + + // ── Retry scheduler ─────────────────────────────────────────────────────────── + + @Cron(CronExpression.EVERY_MINUTE) + async retryFailedDeliveries() { + const due = await this.prisma.webhookDelivery.findMany({ + where: { + status: WebhookDeliveryStatus.FAILED, + attempts: { lt: this.MAX_ATTEMPTS }, + nextRetryAt: { lte: new Date() }, + }, + include: { webhook: true }, + }); + + for (const delivery of due) { + this.logger.log( + `Retrying webhook delivery ${delivery.id} (attempt ${delivery.attempts + 1})`, + ); + await this.prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { status: WebhookDeliveryStatus.RETRYING }, + }); + await this.deliver(delivery.webhook, delivery.id, delivery.payload as object); + } + } + + // ── Delivery status ─────────────────────────────────────────────────────────── + + async getDeliveries(webhookId: string, userId: string) { + await this.findOne(webhookId, userId); + return this.prisma.webhookDelivery.findMany({ + where: { webhookId }, + orderBy: { createdAt: 'desc' }, + take: 50, + select: { + id: true, + eventType: true, + status: true, + attempts: true, + responseStatus: true, + errorMessage: true, + deliveredAt: true, + nextRetryAt: true, + createdAt: true, + }, + }); + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + private sign(body: string, secret: string): string { + return crypto.createHmac('sha256', secret).update(body).digest('hex'); + } + + private nextRetry(attempt: number): Date { + const delaySecs = this.RETRY_DELAYS[attempt - 1] ?? 900; + return new Date(Date.now() + delaySecs * 1000); + } +} \ No newline at end of file