diff --git a/ApiCore-Backend/prisma/migrations/20260823044358_webhooks/migration.sql b/ApiCore-Backend/prisma/migrations/20260823044358_webhooks/migration.sql new file mode 100644 index 0000000..a6a9d9a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260823044358_webhooks/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "WebhookSubscription" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "events" TEXT[], + "secret" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WebhookSubscription_active_idx" ON "WebhookSubscription"("active"); + +-- AddForeignKey +ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 77c0f3c..4b11de7 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -52,6 +52,7 @@ model User { bottlingBatches BottlingBatch[] notifications Notification[] queenEvaluations QueenEvaluation[] + webhookSubscriptions WebhookSubscription[] @@index([role]) } @@ -754,3 +755,21 @@ model Notification { @@index([userId, read]) @@index([priority]) } + +// --------------------------------------------------------------------------- +// Webhooks (Abschnitt 27) – v1.x Modul 28 +// --------------------------------------------------------------------------- + +model WebhookSubscription { + id String @id @default(cuid()) + url String + events String[] + secret String + active Boolean @default(true) + createdByUserId String? + createdByUser User? @relation(fields: [createdByUserId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([active]) +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index e86a0b0..aa847e9 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -28,6 +28,7 @@ import { EquipmentModule } from './equipment/equipment.module'; import { CalendarModule } from './calendar/calendar.module'; import { NotificationsModule } from './notifications/notifications.module'; import { StatisticsModule } from './statistics/statistics.module'; +import { WebhooksModule } from './webhooks/webhooks.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -61,6 +62,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; CalendarModule, NotificationsModule, StatisticsModule, + WebhooksModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index 121c7a1..b9c9476 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BottlingService } from './bottling.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('BottlingService', () => { let service: BottlingService; @@ -39,9 +40,11 @@ describe('BottlingService', () => { ), }; auditLog = { record: jest.fn() }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new BottlingService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index 7fb4721..457f902 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { BottlingBatch } from '../../generated/prisma'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; @@ -22,6 +23,7 @@ export class BottlingService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAll(filter: BottlingBatchFilter = {}) { @@ -103,6 +105,8 @@ export class BottlingService { afterData: bottlingBatch, }); - return this.findById(bottlingBatch.id); + const created = await this.findById(bottlingBatch.id); + await this.webhooks.dispatch('batch.updated', created.honeyBatch); + return created; } } diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index f301d4d..8d6277b 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -2,6 +2,7 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; import { ColoniesService } from './colonies.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('ColoniesService', () => { let service: ColoniesService; @@ -53,9 +54,11 @@ describe('ColoniesService', () => { ), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new ColoniesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 47671e4..85a257e 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { Colony, ColonyStatus } from '../../generated/prisma'; import { CreateColonyDto } from './dto/create-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto'; @@ -22,6 +23,7 @@ export class ColoniesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAll(filter: ColonyFilter = {}): Promise { @@ -56,6 +58,7 @@ export class ColoniesService { userId: actingUserId, afterData: colony, }); + await this.webhooks.dispatch('colony.created', colony); return colony; } diff --git a/ApiCore-Backend/src/harvests/harvests.service.spec.ts b/ApiCore-Backend/src/harvests/harvests.service.spec.ts index f197096..a0eea90 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.spec.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { HarvestsService } from './harvests.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('HarvestsService', () => { let service: HarvestsService; @@ -29,9 +30,11 @@ describe('HarvestsService', () => { ), }; auditLog = { record: jest.fn() }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new HarvestsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/harvests/harvests.service.ts b/ApiCore-Backend/src/harvests/harvests.service.ts index 2056b90..30d4ce9 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { CreateHarvestDto } from './dto/create-harvest.dto'; const harvestInclude = { @@ -12,6 +13,7 @@ export class HarvestsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAll() { @@ -61,6 +63,8 @@ export class HarvestsService { userId: actingUserId, afterData: harvest, }); - return this.findById(harvest.id); + const created = await this.findById(harvest.id); + await this.webhooks.dispatch('harvest.created', created); + return created; } } diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts index d6642ff..5e7ac08 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { HoneyBatchesService } from './honey-batches.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('HoneyBatchesService', () => { let service: HoneyBatchesService; @@ -50,9 +51,11 @@ describe('HoneyBatchesService', () => { honeyMeasurement: { create: jest.fn() }, }; auditLog = { record: jest.fn() }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new HoneyBatchesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 38fb946..12b2fa6 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { HoneyBatchStatus } from '../../generated/prisma'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; @@ -27,6 +28,7 @@ export class HoneyBatchesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAll(filter: HoneyBatchFilter = {}) { @@ -59,7 +61,9 @@ export class HoneyBatchesService { userId: actingUserId, afterData: honeyBatch, }); - return this.findById(honeyBatch.id); + const created = await this.findById(honeyBatch.id); + await this.webhooks.dispatch('batch.created', created); + return created; } async addMeasurement( diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts index 6aa2ff7..a75fbb8 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -1,6 +1,7 @@ import { NotFoundException } from '@nestjs/common'; import { InspectionsService } from './inspections.service'; import { PrismaService } from '../prisma/prisma.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('InspectionsService', () => { let service: InspectionsService; @@ -28,7 +29,11 @@ describe('InspectionsService', () => { }, task: { create: jest.fn() }, }; - service = new InspectionsService(prisma as unknown as PrismaService); + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + service = new InspectionsService( + prisma as unknown as PrismaService, + webhooks as unknown as WebhooksService, + ); }); it('findById throws NotFoundException when missing', async () => { diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index 4a879fb..a264e37 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -1,12 +1,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { Inspection, Task } from '../../generated/prisma'; import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; @Injectable() export class InspectionsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly webhooks: WebhooksService, + ) {} async findAll(): Promise { return this.prisma.inspection.findMany({ orderBy: { date: 'desc' } }); @@ -33,9 +37,11 @@ export class InspectionsService { dto: CreateInspectionDto, actingUserId: string, ): Promise { - return this.prisma.inspection.create({ + const inspection = await this.prisma.inspection.create({ data: { ...dto, date: new Date(dto.date), userId: actingUserId }, }); + await this.webhooks.dispatch('inspection.created', inspection); + return inspection; } async createTaskFromInspection( diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index bbf4191..278ca84 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { QueensService } from './queens.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('QueensService', () => { let service: QueensService; @@ -46,9 +47,11 @@ describe('QueensService', () => { ), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new QueensService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index c8e84ea..8ea901c 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { Queen, QueenStatus } from '../../generated/prisma'; import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; @@ -16,6 +17,7 @@ export class QueensService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAll(filter: QueenFilter = {}): Promise { @@ -42,6 +44,7 @@ export class QueensService { userId: actingUserId, afterData: queen, }); + await this.webhooks.dispatch('queen.created', queen); return queen; } @@ -117,7 +120,9 @@ export class QueensService { afterData: { ...queen, assignedColonyId: colony.id }, }); - return this.findById(id); + const updated = await this.findById(id); + await this.webhooks.dispatch('queen.replaced', updated); + return updated; } async getHistory(id: string) { diff --git a/ApiCore-Backend/src/storage/storage.service.spec.ts b/ApiCore-Backend/src/storage/storage.service.spec.ts index cb93a02..18dea72 100644 --- a/ApiCore-Backend/src/storage/storage.service.spec.ts +++ b/ApiCore-Backend/src/storage/storage.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { StorageService } from './storage.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('StorageService', () => { let service: StorageService; @@ -45,9 +46,11 @@ describe('StorageService', () => { ), }; auditLog = { record: jest.fn() }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new StorageService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts index 4a625e4..e2dacf2 100644 --- a/ApiCore-Backend/src/storage/storage.service.ts +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { StockItem, StorageContainer } from '../../generated/prisma'; import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; import { CreateStockItemDto } from './dto/create-stock-item.dto'; @@ -11,6 +12,7 @@ export class StorageService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAllContainers(): Promise { @@ -96,6 +98,8 @@ export class StorageService { userId: actingUserId, afterData: movement, }); - return this.findStockItemById(id); + const stockItem = await this.findStockItemById(id); + await this.webhooks.dispatch('product.stock_changed', stockItem); + return stockItem; } } diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts index 1bc21da..89d25c3 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.spec.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { TasksService } from './tasks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; describe('TasksService', () => { let service: TasksService; @@ -27,9 +28,11 @@ describe('TasksService', () => { }, }; auditLog = { record: jest.fn() }; + const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new TasksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + webhooks as unknown as WebhooksService, ); }); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index 0b260da..c7e89af 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; @@ -15,6 +16,7 @@ export class TasksService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly webhooks: WebhooksService, ) {} async findAll(filter: TaskFilter): Promise { @@ -47,6 +49,7 @@ export class TasksService { userId: actingUserId, afterData: task, }); + await this.webhooks.dispatch('task.created', task); return task; } @@ -88,6 +91,7 @@ export class TasksService { beforeData: before, afterData: task, }); + await this.webhooks.dispatch('task.completed', task); return task; } } diff --git a/ApiCore-Backend/src/webhooks/dto/create-webhook.dto.ts b/ApiCore-Backend/src/webhooks/dto/create-webhook.dto.ts new file mode 100644 index 0000000..706f769 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/dto/create-webhook.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsIn, IsUrl } from 'class-validator'; +import { WEBHOOK_EVENTS, WebhookEvent } from '../webhook-events'; + +export class CreateWebhookDto { + @ApiProperty() + @IsUrl({ require_tld: false }) + url!: string; + + @ApiProperty({ enum: WEBHOOK_EVENTS, isArray: true }) + @IsArray() + @ArrayMinSize(1) + @IsIn(WEBHOOK_EVENTS, { each: true }) + events!: WebhookEvent[]; +} diff --git a/ApiCore-Backend/src/webhooks/webhook-events.ts b/ApiCore-Backend/src/webhooks/webhook-events.ts new file mode 100644 index 0000000..d6a3e04 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-events.ts @@ -0,0 +1,14 @@ +export const WEBHOOK_EVENTS = [ + 'colony.created', + 'inspection.created', + 'queen.created', + 'queen.replaced', + 'harvest.created', + 'batch.created', + 'batch.updated', + 'product.stock_changed', + 'task.created', + 'task.completed', +] as const; + +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; diff --git a/ApiCore-Backend/src/webhooks/webhooks.controller.ts b/ApiCore-Backend/src/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..dc57e87 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhooks.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { WebhooksService } from './webhooks.service'; + +@ApiTags('webhooks') +@ApiBearerAuth() +@Roles(Role.ADMIN) +@Controller('webhooks') +export class WebhooksController { + constructor(private readonly webhooksService: WebhooksService) {} + + @Get() + findAll() { + return this.webhooksService.findAll(); + } + + @Post() + create( + @Body() dto: CreateWebhookDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.webhooksService.create(dto, actor.id); + } + + @Delete(':id') + delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.webhooksService.delete(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/webhooks/webhooks.module.ts b/ApiCore-Backend/src/webhooks/webhooks.module.ts new file mode 100644 index 0000000..b137c71 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhooks.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { WebhooksController } from './webhooks.controller'; +import { WebhooksService } from './webhooks.service'; + +@Global() +@Module({ + controllers: [WebhooksController], + providers: [WebhooksService], + exports: [WebhooksService], +}) +export class WebhooksModule {} diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts new file mode 100644 index 0000000..6e490ec --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts @@ -0,0 +1,120 @@ +import * as crypto from 'node:crypto'; +import { NotFoundException } from '@nestjs/common'; +import { WebhooksService } from './webhooks.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('WebhooksService', () => { + let service: WebhooksService; + let prisma: { + webhookSubscription: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + delete: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + let fetchMock: jest.SpiedFunction; + + beforeEach(() => { + prisma = { + webhookSubscription: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new WebhooksService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(null, { status: 200 })); + }); + + it('create generates a random secret and stores it', async () => { + prisma.webhookSubscription.create.mockImplementation( + ({ data }: { data: Record }) => ({ + id: 'wh-1', + ...data, + }), + ); + + const subscription = await service.create( + { url: 'https://example.test/hook', events: ['colony.created'] }, + 'user-1', + ); + + expect(subscription.secret).toHaveLength(64); + const [[createArg]] = prisma.webhookSubscription.create.mock.calls as [ + [{ data: { url: string; events: string[]; createdByUserId: string } }], + ]; + expect(createArg.data.url).toBe('https://example.test/hook'); + expect(createArg.data.events).toEqual(['colony.created']); + expect(createArg.data.createdByUserId).toBe('user-1'); + }); + + it('delete throws NotFoundException when the subscription is missing', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValue(null); + await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('dispatch only queries subscriptions matching the event and active flag', async () => { + await service.dispatch('colony.created', { id: 'colony-1' }); + + expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({ + where: { active: true, events: { has: 'colony.created' } }, + }); + }); + + it('dispatch signs the payload with HMAC-SHA256 and posts it with event headers', async () => { + const subscription = { + id: 'wh-1', + url: 'https://example.test/hook', + secret: 'topsecret', + active: true, + }; + prisma.webhookSubscription.findMany.mockResolvedValueOnce([subscription]); + + await service.dispatch('colony.created', { id: 'colony-1' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as [ + string, + { method: string; headers: Record; body: string }, + ]; + expect(url).toBe('https://example.test/hook'); + expect(options.method).toBe('POST'); + expect(options.headers['X-ApiCore-Event']).toBe('colony.created'); + + const expectedSignature = crypto + .createHmac('sha256', subscription.secret) + .update(options.body) + .digest('hex'); + expect(options.headers['X-ApiCore-Signature']).toBe( + `sha256=${expectedSignature}`, + ); + }); + + it('dispatch does not throw when delivery fails', async () => { + prisma.webhookSubscription.findMany.mockResolvedValueOnce([ + { + id: 'wh-1', + url: 'https://example.test/hook', + secret: 's', + active: true, + }, + ]); + fetchMock.mockRejectedValueOnce(new Error('network down')); + + await expect( + service.dispatch('colony.created', { id: 'colony-1' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.ts b/ApiCore-Backend/src/webhooks/webhooks.service.ts new file mode 100644 index 0000000..f2f62a6 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhooks.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import * as crypto from 'node:crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { WebhookSubscription } from '../../generated/prisma'; +import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { WebhookEvent } from './webhook-events'; + +const SECRET_BYTES = 32; + +@Injectable() +export class WebhooksService { + private readonly logger = new Logger(WebhooksService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise[]> { + return this.prisma.webhookSubscription.findMany({ + select: { + id: true, + url: true, + events: true, + active: true, + createdByUserId: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async create( + dto: CreateWebhookDto, + actingUserId: string, + ): Promise { + const secret = crypto.randomBytes(SECRET_BYTES).toString('hex'); + const subscription = await this.prisma.webhookSubscription.create({ + data: { + url: dto.url, + events: dto.events, + secret, + createdByUserId: actingUserId, + }, + }); + await this.auditLog.record({ + entityType: 'WebhookSubscription', + entityId: subscription.id, + action: 'CREATE', + userId: actingUserId, + afterData: { ...subscription, secret: undefined }, + }); + return subscription; + } + + async delete(id: string, actingUserId: string): Promise { + const subscription = await this.prisma.webhookSubscription.findUnique({ + where: { id }, + }); + if (!subscription) { + throw new NotFoundException('Webhook subscription not found'); + } + await this.prisma.webhookSubscription.delete({ where: { id } }); + await this.auditLog.record({ + entityType: 'WebhookSubscription', + entityId: id, + action: 'DELETE', + userId: actingUserId, + beforeData: { ...subscription, secret: undefined }, + }); + } + + async dispatch(event: WebhookEvent, payload: unknown): Promise { + const subscriptions = await this.prisma.webhookSubscription.findMany({ + where: { active: true, events: { has: event } }, + }); + await Promise.all( + subscriptions.map((subscription) => + this.deliver(subscription, event, payload), + ), + ); + } + + private async deliver( + subscription: WebhookSubscription, + event: WebhookEvent, + payload: unknown, + ): Promise { + const body = JSON.stringify({ event, data: payload }); + const signature = crypto + .createHmac('sha256', subscription.secret) + .update(body) + .digest('hex'); + try { + await fetch(subscription.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-ApiCore-Event': event, + 'X-ApiCore-Signature': `sha256=${signature}`, + }, + body, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Webhook delivery to ${subscription.url} failed for event ${event}: ${message}`, + ); + } + } +} diff --git a/ApiCore-Backend/test/webhooks.e2e-spec.ts b/ApiCore-Backend/test/webhooks.e2e-spec.ts new file mode 100644 index 0000000..12c9ca5 --- /dev/null +++ b/ApiCore-Backend/test/webhooks.e2e-spec.ts @@ -0,0 +1,175 @@ +import { INestApplication } from '@nestjs/common'; +import * as http from 'node:http'; +import * as crypto from 'node:crypto'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface WebhookSubscriptionBody { + id: string; + url: string; + events: string[]; + secret: string; + active: boolean; +} + +interface ReceivedRequest { + body: string; + headers: http.IncomingHttpHeaders; +} + +describe('Webhooks (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const adminEmail = `webhooks-e2e-admin-${Date.now()}@example.com`; + const staffEmail = `webhooks-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let adminId: string; + let staffId: string; + let adminAccessToken: string; + let staffAccessToken: string; + let subscriptionId: string; + + let receiverServer: http.Server; + let receiverUrl: string; + let receivedRequests: ReceivedRequest[] = []; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const admin = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + name: 'Webhooks E2E Admin', + role: 'ADMIN', + }, + }); + adminId = admin.id; + const staff = await prisma.user.create({ + data: { + email: staffEmail, + passwordHash, + name: 'Webhooks E2E Staff', + role: 'STAFF', + }, + }); + staffId = staff.id; + adminAccessToken = (await login(app, adminEmail, password)).accessToken; + staffAccessToken = (await login(app, staffEmail, password)).accessToken; + + receivedRequests = []; + receiverServer = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + receivedRequests.push({ + body: Buffer.concat(chunks).toString('utf-8'), + headers: req.headers, + }); + res.writeHead(200); + res.end('ok'); + }); + }); + await new Promise((resolve) => + receiverServer.listen(0, '127.0.0.1', resolve), + ); + const address = receiverServer.address(); + if (address === null || typeof address === 'string') { + throw new Error('Failed to determine receiver server address'); + } + receiverUrl = `http://127.0.0.1:${address.port}/hook`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + receiverServer.close((error) => (error ? reject(error) : resolve())), + ); + await prisma.webhookSubscription.deleteMany({ + where: { id: subscriptionId }, + }); + await prisma.task.deleteMany({ + where: { createdByUserId: { in: [adminId, staffId] } }, + }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [adminId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [adminId, staffId] } }, + }); + await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } }); + await app.close(); + }); + + it('rejects non-admin roles from listing webhooks', () => { + return request(app.getHttpServer()) + .get('/api/v1/webhooks') + .set('Authorization', `Bearer ${staffAccessToken}`) + .expect(403); + }); + + it('lets an admin create a webhook subscription with a generated secret', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/webhooks') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ url: receiverUrl, events: ['task.created'] }) + .expect(201); + const body = response.body as WebhookSubscriptionBody; + subscriptionId = body.id; + expect(body.secret).toHaveLength(64); + expect(body.events).toEqual(['task.created']); + }); + + it('excludes the secret from the list response', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/webhooks') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + const body = response.body as Partial[]; + const listed = body.find((entry) => entry.id === subscriptionId); + expect(listed?.secret).toBeUndefined(); + }); + + it('delivers an HMAC-signed POST to the subscribed URL when task.created fires', async () => { + const subscription = await prisma.webhookSubscription.findUniqueOrThrow({ + where: { id: subscriptionId }, + }); + + const taskResponse = await request(app.getHttpServer()) + .post('/api/v1/tasks') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ title: `Webhook-Test ${Date.now()}` }) + .expect(201); + + expect(receivedRequests).toHaveLength(1); + const delivery = receivedRequests[0]; + expect(delivery.headers['x-apicore-event']).toBe('task.created'); + + const expectedSignature = crypto + .createHmac('sha256', subscription.secret) + .update(delivery.body) + .digest('hex'); + expect(delivery.headers['x-apicore-signature']).toBe( + `sha256=${expectedSignature}`, + ); + + const payload = JSON.parse(delivery.body) as { + event: string; + data: { id: string }; + }; + expect(payload.event).toBe('task.created'); + expect(payload.data.id).toBe((taskResponse.body as { id: string }).id); + }); + + it('lets an admin delete a webhook subscription', () => { + return request(app.getHttpServer()) + .delete(`/api/v1/webhooks/${subscriptionId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + }); +});