diff --git a/src/common/events/dto/sse-event.dto.ts b/src/common/events/dto/sse-event.dto.ts new file mode 100644 index 0000000..85891eb --- /dev/null +++ b/src/common/events/dto/sse-event.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PolicyStatusEventDto { + @ApiProperty({ description: 'Policy UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + policyId: string; + + @ApiProperty({ + description: 'Current policy status', + example: 'ACTIVE', + enum: ['ACTIVE', 'EXPIRED', 'CANCELLED', 'CLAIMED', 'PROCESSING'], + }) + status: string; + + @ApiProperty({ description: 'Unix timestamp in milliseconds', example: 1700000000000 }) + timestamp: number; +} diff --git a/src/common/webhooks/dto/webhook.dto.ts b/src/common/webhooks/dto/webhook.dto.ts new file mode 100644 index 0000000..f7746ac --- /dev/null +++ b/src/common/webhooks/dto/webhook.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUrl, IsArray, ArrayNotEmpty, ArrayUnique, IsOptional, IsString, IsEnum } from 'class-validator'; + +export enum WebhookEventType { + POLICY_STATUS_CHANGE = 'policy.status.change', + CLAIM_STATUS_CHANGE = 'claim.status.change', +} + +export class RegisterWebhookDto { + @ApiProperty({ description: 'URL to receive webhook POST requests', example: 'https://example.com/webhook' }) + @IsUrl() + url: string; + + @ApiProperty({ + description: 'Event types to subscribe to', + enum: WebhookEventType, + isArray: true, + example: [WebhookEventType.POLICY_STATUS_CHANGE, WebhookEventType.CLAIM_STATUS_CHANGE], + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsEnum(WebhookEventType, { each: true }) + events: WebhookEventType[]; + + @ApiPropertyOptional({ + description: 'Shared secret for HMAC-SHA256 signature verification. If provided, each delivery includes an X-Webhook-Signature header.', + example: 'whsec_abc123', + }) + @IsOptional() + @IsString() + secret?: string; +} + +export class WebhookRegistrationResponseDto { + @ApiProperty({ description: 'Unique webhook registration ID', example: '1700000000000-abc1234' }) + id: string; + + @ApiProperty({ description: 'Registration status', example: 'registered' }) + status: string; +} + +export class WebhookListItemDto { + @ApiProperty({ description: 'Unique webhook registration ID', example: '1700000000000-abc1234' }) + id: string; + + @ApiProperty({ description: 'Target URL for deliveries', example: 'https://example.com/webhook' }) + url: string; + + @ApiProperty({ description: 'Subscribed event types', enum: WebhookEventType, isArray: true }) + events: WebhookEventType[]; + + @ApiProperty({ description: 'Whether the webhook is active', example: true }) + isActive: boolean; + + @ApiProperty({ description: 'Registration timestamp' }) + createdAt: Date; +} + +export class PolicyStatusChangePayloadDto { + @ApiProperty({ description: 'Policy UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + policyId: string; + + @ApiProperty({ description: 'Previous policy status', example: 'ACTIVE', enum: ['ACTIVE', 'EXPIRED', 'CANCELLED', 'CLAIMED', 'PROCESSING'] }) + fromStatus: string; + + @ApiProperty({ description: 'New policy status', example: 'CLAIMED', enum: ['ACTIVE', 'EXPIRED', 'CANCELLED', 'CLAIMED', 'PROCESSING'] }) + toStatus: string; + + @ApiProperty({ description: 'Unix timestamp in milliseconds', example: 1700000000000 }) + timestamp: number; +} + +export class ClaimStatusChangePayloadDto { + @ApiProperty({ description: 'Claim UUID', example: 'b2c3d4e5-f6a7-8901-bcde-f12345678901' }) + claimId: string; + + @ApiProperty({ description: 'Previous claim status', example: 'PROCESSING' }) + fromStatus: string; + + @ApiProperty({ description: 'New claim status', example: 'PAID' }) + toStatus: string; + + @ApiProperty({ description: 'Unix timestamp in milliseconds', example: 1700000000000 }) + timestamp: number; +} diff --git a/src/common/webhooks/webhooks.controller.ts b/src/common/webhooks/webhooks.controller.ts index 16ddafe..b9a4224 100644 --- a/src/common/webhooks/webhooks.controller.ts +++ b/src/common/webhooks/webhooks.controller.ts @@ -1,10 +1,18 @@ -import { Controller, Post, Body, Get, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { Controller, Post, Body, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiExtraModels, getSchemaPath } from '@nestjs/swagger'; import { WebhooksService } from '../events/webhooks.service'; import { PrismaService } from '../prisma/prisma.service'; +import { + RegisterWebhookDto, + WebhookRegistrationResponseDto, + WebhookListItemDto, + PolicyStatusChangePayloadDto, + ClaimStatusChangePayloadDto, +} from './dto/webhook.dto'; @Controller('webhooks') @ApiTags('webhooks') +@ApiExtraModels(RegisterWebhookDto, WebhookRegistrationResponseDto, WebhookListItemDto, PolicyStatusChangePayloadDto, ClaimStatusChangePayloadDto) export class WebhooksController { constructor( private readonly webhooks: WebhooksService, @@ -13,14 +21,32 @@ export class WebhooksController { /** POST /api/v1/webhooks/register — register a webhook endpoint */ @Post('register') - @ApiOperation({ summary: 'Register a webhook for policy/claim status changes' }) + @ApiOperation({ + summary: 'Register a webhook for real-time event notifications', + description: + 'Register a URL to receive POST requests when specific events occur. ' + + 'Supported event types:\n\n' + + '| Event | Description | Payload |\n' + + '|-------|-------------|---------|\n' + + '| `policy.status.change` | A policy status transition (e.g. ACTIVE → CLAIMED) | `{ policyId, fromStatus, toStatus, timestamp }` |\n' + + '| `claim.status.change` | A claim status transition (e.g. PROCESSING → PAID) | `{ claimId, fromStatus, toStatus, timestamp }` |\n\n' + + '**Signature verification:** If a `secret` is provided, each delivery includes an `X-Webhook-Signature` header ' + + 'containing an HMAC-SHA256 digest of the JSON payload, base64-encoded. Verify with:\n' + + '```\n' + + 'crypto.createHmac("sha256", secret).update(rawBody).digest("base64")\n' + + '```', + }) @ApiBearerAuth() - @ApiResponse({ status: 201, description: 'Webhook registered successfully' }) + @ApiResponse({ + status: 201, + description: 'Webhook registered successfully', + schema: { $ref: getSchemaPath(WebhookRegistrationResponseDto) }, + }) @ApiResponse({ status: 400, description: 'Invalid request body' }) - register(@Body() dto: { url: string; events: string[]; secret?: string }) { + register(@Body() dto: RegisterWebhookDto) { return this.webhooks.registerWebhook({ url: dto.url, - events: dto.events as ('policy.status.change' | 'claim.status.change')[], + events: dto.events, secret: dto.secret, }); } @@ -29,7 +55,11 @@ export class WebhooksController { @Get() @ApiOperation({ summary: 'List all registered webhooks' }) @ApiBearerAuth() - @ApiResponse({ status: 200, description: 'Returns list of registered webhooks' }) + @ApiResponse({ + status: 200, + description: 'Returns list of active webhook registrations', + schema: { type: 'array', items: { $ref: getSchemaPath(WebhookListItemDto) } }, + }) list() { return this.webhooks.getRegistrations(); } diff --git a/src/main.ts b/src/main.ts index 5511e06..bf61c8e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -138,6 +138,8 @@ async function bootstrap() { .addTag('oracle', 'Oracle data feeds and readings') .addTag('auth', 'Wallet-based authentication') .addTag('health', 'Service health monitoring') + .addTag('webhooks', 'Webhook registration and real-time event subscriptions') + .addTag('events', 'Server-Sent Events (SSE) for real-time policy status streaming') .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); SwaggerModule.setup('docs', app, document); diff --git a/src/policy/policy.controller.ts b/src/policy/policy.controller.ts index fdcfb14..da1f975 100644 --- a/src/policy/policy.controller.ts +++ b/src/policy/policy.controller.ts @@ -39,10 +39,11 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { OperatorAuthGuard } from '../auth/operator-auth.guard'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { StatusEventsService } from '../common/events/status-events.service'; +import { PolicyStatusEventDto } from '../common/events/dto/sse-event.dto'; @ApiTags('policy') @Controller() -@ApiExtraModels(ResponseDto, PaginatedResponseDto, ProductResponseDto, PolicyResponseDto) +@ApiExtraModels(ResponseDto, PaginatedResponseDto, ProductResponseDto, PolicyResponseDto, PolicyStatusEventDto) export class PolicyController { constructor( private readonly policy: PolicyService, @@ -376,8 +377,30 @@ export class PolicyController { @Sse('policies/:id/events') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'Server-Sent Events stream of status changes for a policy' }) + @ApiOperation({ + summary: 'Server-Sent Events stream of status changes for a policy', + description: + 'Opens an SSE connection that streams policy status transitions in real time. ' + + 'The current status is emitted immediately on connection, followed by events whenever the status changes. ' + + 'Possible status values: ACTIVE, PROCESSING, CLAIMED, CANCELLED, EXPIRED.\n\n' + + 'Event data schema:\n' + + '```json\n' + + '{ "policyId": "uuid", "status": "ACTIVE", "timestamp": 1700000000000 }\n' + + '```\n\n' + + 'Connect with `EventSource`:\n' + + '```js\n' + + 'const es = new EventSource("/api/v1/policies/:id/events", { withCredentials: true });\n' + + "es.onmessage = (e) => console.log(JSON.parse(e.data));\n" + + '```', + }) @ApiParam({ name: 'id', description: 'Policy UUID' }) + @ApiResponse({ + status: 200, + description: 'SSE stream of PolicyStatusEvent objects. Each message has a `data` field containing the event payload.', + schema: { $ref: getSchemaPath(PolicyStatusEventDto) }, + }) + @ApiResponse({ status: 403, description: 'Policy belongs to a different wallet' }) + @ApiResponse({ status: 404, description: 'Policy not found' }) async policyStatusEvents( @Param('id') id: string, @Req() req: AuthenticatedRequest,