Skip to content
Open
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
68 changes: 68 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,71 @@ model WorldIdVerification {

@@map("world_id_verifications")
}

model Webhook {
id String @id @default(uuid())
url String
description String?
ownerId String
enabled Boolean @default(true)
secret String
secretExpiresAt DateTime?
previousSecret String?
previousSecretExpiresAt DateTime?
consecutiveFailures Int @default(0)
maxRetries Int @default(3)
retryIntervalMs Int @default(30000)
disabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

subscriptions WebhookSubscription[]
deliveries WebhookDelivery[]

@@index([ownerId])
@@index([enabled])
@@map("webhooks")
}

model WebhookSubscription {
id String @id @default(uuid())
webhookId String
eventType String
filters Json?
createdAt DateTime @default(now())

webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)

@@unique([webhookId, eventType])
@@index([webhookId])
@@index([eventType])
@@map("webhook_subscriptions")
}

model WebhookDelivery {
id String @id @default(uuid())
webhookId String
eventType String
payload Json
status String @default("PENDING")
responseStatus Int?
responseBody String?
latency Int?
retryCount Int @default(0)
maxRetries Int
failureReason String?
requestId String
nonce String
signature String
timestamp String
createdAt DateTime @default(now())
completedAt DateTime?

webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)

@@index([webhookId])
@@index([status])
@@index([createdAt])
@@index([webhookId, status])
@@map("webhook_deliveries")
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import { AuthModule } from './auth/auth.module';
import { GlobalAuthGuard } from './auth/global-auth.guard';
import { MetricsModule } from './metrics/metrics.module';
import { WebhooksModule } from './webhooks/webhooks.module';

// In-memory storage for development (no Redis needed)
class ThrottlerMemoryStorage {
Expand All @@ -52,12 +53,12 @@

async increment(
key: string,
ttl: number,

Check failure on line 56 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Async method 'increment' has no 'await' expression
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }> {
const now = Date.now();

Check failure on line 61 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

'throttlerName' is defined but never used
const record = this.storage.get(key);

if (!record) {
Expand Down Expand Up @@ -131,7 +132,7 @@
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,

Check failure on line 135 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }> {
const blockKey = `${key}:blocked`;
const [blocked, blockTimeToExpire] = await Promise.all([
Expand All @@ -140,7 +141,7 @@
]);

if (blocked) {
const timeToExpire = await this.redis.pttl(key);

Check failure on line 144 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

'throttlerName' is defined but never used
return {
totalHits: await this.redis.get(key).then((value: string | null) => Number(value) || limit + 1),
timeToExpire: timeToExpire > 0 ? timeToExpire : ttl,
Expand All @@ -148,7 +149,7 @@
timeToBlockExpire: blockTimeToExpire > 0 ? blockTimeToExpire : 0,
};
}

Check failure on line 152 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe array destructuring of a tuple element with an `any` value
const [totalHits, existingTtl] = await Promise.all([
this.redis.incr(key),
this.redis.pttl(key),
Expand Down Expand Up @@ -283,6 +284,7 @@
AuditModule,
ThemeModule,
MetricsModule,
WebhooksModule,
],
controllers: [AppController],
providers: [
Expand Down
1 change: 1 addition & 0 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function configureApp(app: INestApplication) {
.addTag('leaderboard', 'User leaderboard rankings')
.addTag('audit', 'Audit log retrieval')
.addTag('health', 'Health check endpoints')
.addTag('webhooks', 'Webhook management and event delivery')
.build();

const document = SwaggerModule.createDocument(app, config);
Expand Down
83 changes: 83 additions & 0 deletions src/webhooks/dto/create-webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
IsString,
IsUrl,
IsOptional,
IsArray,
IsBoolean,
IsNumber,
Min,
Max,
ArrayNotEmpty,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { WebhookEventType } from '../entities/webhook.entity';

export class CreateWebhookDto {
@ApiProperty({
description: 'HTTPS endpoint URL that will receive webhook events',
example: 'https://api.example.com/webhooks/truthbounty',
})
@IsUrl({ protocols: ['https'], require_tld: false })
@IsString()
url: string;

@ApiPropertyOptional({
description: 'Human-readable description of this webhook',
example: 'Production analytics dashboard',
})
@IsOptional()
@IsString()
description?: string;

@ApiProperty({
description: 'Blockchain wallet address that owns this webhook',
example: '0x1234567890abcdef1234567890abcdef12345678',
})
@IsString()
ownerId: string;

@ApiPropertyOptional({
description: 'Whether the webhook is active (default: true)',
default: true,
})
@IsOptional()
@IsBoolean()
enabled?: boolean;

@ApiProperty({
description: 'Event types to subscribe to',
example: ['claim.created', 'verification.completed', 'reward.distributed'],
enum: WebhookEventType,
isArray: true,
})
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
events: WebhookEventType[];

@ApiPropertyOptional({
description: 'Optional JSON filters for event payload filtering',
example: { network: 'mainnet', severity: 'high' },
})
@IsOptional()
filters?: Record<string, any>;

@ApiPropertyOptional({
description: 'Maximum number of retry attempts for failed deliveries (default: 3)',
default: 3,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(10)
maxRetries?: number;

@ApiPropertyOptional({
description: 'Base retry interval in milliseconds (default: 30000)',
default: 30000,
})
@IsOptional()
@IsNumber()
@Min(1000)
retryIntervalMs?: number;
}
72 changes: 72 additions & 0 deletions src/webhooks/dto/update-webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
IsString,
IsUrl,
IsOptional,
IsArray,
IsBoolean,
IsNumber,
Min,
Max,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { WebhookEventType } from '../entities/webhook.entity';

export class UpdateWebhookDto {
@ApiPropertyOptional({
description: 'HTTPS endpoint URL that will receive webhook events',
example: 'https://api.example.com/webhooks/truthbounty',
})
@IsOptional()
@IsUrl({ protocols: ['https'], require_tld: false })
@IsString()
url?: string;

@ApiPropertyOptional({
description: 'Human-readable description of this webhook',
})
@IsOptional()
@IsString()
description?: string;

@ApiPropertyOptional({
description: 'Whether the webhook is active',
})
@IsOptional()
@IsBoolean()
enabled?: boolean;

@ApiPropertyOptional({
description: 'Event types to subscribe to',
enum: WebhookEventType,
isArray: true,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
events?: WebhookEventType[];

@ApiPropertyOptional({
description: 'Optional JSON filters for event payload filtering',
})
@IsOptional()
filters?: Record<string, any>;

@ApiPropertyOptional({
description: 'Maximum number of retry attempts for failed deliveries',
default: 3,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(10)
maxRetries?: number;

@ApiPropertyOptional({
description: 'Base retry interval in milliseconds',
default: 30000,
})
@IsOptional()
@IsNumber()
@Min(1000)
retryIntervalMs?: number;
}
55 changes: 55 additions & 0 deletions src/webhooks/dto/webhook-filter.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { IsOptional, IsString, IsEnum, IsNumber, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { DeliveryStatus } from '../entities/webhook-delivery.entity';

export class WebhookDeliveryFilterDto {
@ApiPropertyOptional({
description: 'Filter by event type',
example: 'claim.created',
})
@IsOptional()
@IsString()
eventType?: string;

@ApiPropertyOptional({
description: 'Filter by delivery status',
enum: DeliveryStatus,
})
@IsOptional()
@IsEnum(DeliveryStatus)
status?: DeliveryStatus;

@ApiPropertyOptional({
description: 'Page number (1-based)',
default: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
page?: number;

@ApiPropertyOptional({
description: 'Items per page',
default: 20,
})
@IsOptional()
@IsNumber()
@Min(1)
limit?: number;
}

export class WebhookListFilterDto {
@ApiPropertyOptional({
description: 'Filter by enabled/disabled status',
})
@IsOptional()
@IsString()
enabled?: string;

@ApiPropertyOptional({
description: 'Filter by owner wallet address',
})
@IsOptional()
@IsString()
ownerId?: string;
}
79 changes: 79 additions & 0 deletions src/webhooks/entities/webhook-delivery.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Webhook } from './webhook.entity';

export enum DeliveryStatus {
PENDING = 'PENDING',
DELIVERED = 'DELIVERED',
FAILED = 'FAILED',
DEAD_LETTER = 'DEAD_LETTER',
}

@Entity('webhook_deliveries')
@Index(['webhookId'])
@Index(['status'])
@Index(['createdAt'])
@Index(['webhookId', 'status'])
export class WebhookDelivery {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
webhookId: string;

@ManyToOne(() => Webhook, (webhook) => webhook.deliveries, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'webhookId' })
webhook: Webhook;

@Column()
eventType: string;

@Column({ type: 'json' })
payload: Record<string, any>;

@Column({ type: 'varchar', default: DeliveryStatus.PENDING })
status: DeliveryStatus;

@Column({ nullable: true })
responseStatus: number;

@Column({ type: 'text', nullable: true })
responseBody: string;

@Column({ nullable: true })
latency: number;

@Column({ default: 0 })
retryCount: number;

@Column()
maxRetries: number;

@Column({ type: 'text', nullable: true })
failureReason: string | null;

@Column()
requestId: string;

@Column()
nonce: string;

@Column()
signature: string;

@Column()
timestamp: string;

@CreateDateColumn()
createdAt: Date;

@Column({ type: 'datetime', nullable: true })
completedAt: Date | null;
}
Loading
Loading