Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/common/events/dto/sse-event.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
86 changes: 86 additions & 0 deletions src/common/webhooks/dto/webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 37 additions & 7 deletions src/common/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
});
}
Expand All @@ -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();
}
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions src/policy/policy.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading