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
97 changes: 87 additions & 10 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"@types/bull": "^3.15.9",
"@types/express": "^4.17.25",
"@types/jest": "^29.5.14",
"@types/node": "^26.0.1",
"@types/node": "^26.2.0",
"@types/nodemailer": "^6.4.14",
"@types/passport-jwt": "^3.0.8",
"@types/serve-favicon": "^2.5.7",
Expand All @@ -129,12 +129,12 @@
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"cross-env": "^7.0.3",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"cross-env": "^7.0.3",
"nodemon": "^3.1.11",
"pg": "^8.22.0",
"pino-pretty": "^13.1.3",
Expand Down
13 changes: 13 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ import { AlertPreference } from "./growth/alerts/entities/alert-preference.entit
import { AgentReview } from "./discovery/reviews/entities/agent-review.entity";
import { AgentReviewsModule } from "./discovery/reviews/agent-reviews.module";

// Webhook entities
import { WebhookSubscription } from "./infrastructure/webhooks/entities/webhook-subscription.entity";
import { WebhookEvent } from "./infrastructure/webhooks/entities/webhook-event.entity";
import { WebhookDelivery } from "./infrastructure/webhooks/entities/webhook-delivery.entity";
import { WebhookDeadLetter } from "./infrastructure/webhooks/entities/webhook-dead-letter.entity";
// Modules – webhooks
import { WebhookModule } from "./infrastructure/webhooks/webhook.module";

// Guards
import { APP_FILTER } from "@nestjs/core";
import { QuotaGuard } from "./common/guard/quota.guard";
Expand Down Expand Up @@ -180,6 +188,10 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware";
AlertPreference,
EmailLog,
AgentReview,
WebhookSubscription,
WebhookEvent,
WebhookDelivery,
WebhookDeadLetter,
],
synchronize: true,
logging: true,
Expand Down Expand Up @@ -211,6 +223,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware";
ProfilingModule,
EmailModule,
AgentReviewsModule,
WebhookModule,
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
Expand Down
54 changes: 54 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,58 @@ export class EnvironmentVariables {
@IsBoolean()
@Transform(({ value }) => value === "true")
REFERRAL_ENABLE_VPN_DETECTION?: boolean = false;

// ── Webhook & Reliable Event Delivery ─────────────────────────────

/** Default max retries for webhook deliveries. Default 5. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 5)
WEBHOOK_DEFAULT_MAX_RETRIES?: number = 5;

/** Default base retry delay in ms. Default 1000. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 1000)
WEBHOOK_DEFAULT_RETRY_DELAY_MS?: number = 1000;

/** Default backoff multiplier. Default 2. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseFloat(value) || 2)
WEBHOOK_BACKOFF_MULTIPLIER?: number = 2;

/** Default HTTP request timeout in ms. Default 30000. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 30000)
WEBHOOK_TIMEOUT_MS?: number = 30000;

/** Default max deliveries per minute per subscription. Default 10. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 10)
WEBHOOK_RATE_LIMIT_PER_MINUTE?: number = 10;

/** Max concurrent delivery workers. Default 5. */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 5)
WEBHOOK_CONCURRENCY?: number = 5;

/** Redis host for webhook Bull queue (falls back to REDIS_HOST). */
@IsOptional()
@IsString()
REDIS_HOST?: string;

/** Redis port for webhook Bull queue (falls back to 6379). */
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value, 10) || 6379)
REDIS_PORT?: number;

/** Redis password for webhook Bull queue. */
@IsOptional()
@IsString()
REDIS_PASSWORD?: string;
}
192 changes: 192 additions & 0 deletions src/infrastructure/webhooks/dto/webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import {
IsString,
IsUrl,
IsArray,
IsOptional,
IsEnum,
IsInt,
IsNumber,
IsObject,
Min,
Max,
MaxLength,
MinLength,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { WebhookSubscriptionStatus } from "../entities/webhook-subscription.entity";

export class CreateWebhookSubscriptionDto {
@ApiProperty({ example: "https://example.com/webhook" })
@IsUrl({}, { message: "url must be a valid URL" })
@MaxLength(2048)
url: string;

@ApiProperty({
example: ["portfolio.rebalanced", "alert.triggered"],
description:
"List of event types to subscribe to. Use '*' to receive all events.",
})
@IsArray()
@IsString({ each: true })
events: string[];

@ApiPropertyOptional({ example: "Portfolio monitoring webhook" })
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;

@ApiPropertyOptional({
example: 5,
description: "Max retry attempts per delivery",
})
@IsOptional()
@IsInt()
@Min(1)
@Max(20)
maxRetries?: number;

@ApiPropertyOptional({ example: 1000, description: "Base retry delay in ms" })
@IsOptional()
@IsNumber()
@Min(100)
@Max(60000)
retryDelayMs?: number;

@ApiPropertyOptional({
example: 2,
description: "Exponential backoff multiplier",
})
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
backoffMultiplier?: number;

@ApiPropertyOptional({
example: 30000,
description: "HTTP request timeout in ms",
})
@IsOptional()
@IsNumber()
@Min(1000)
@Max(120000)
timeoutMs?: number;

@ApiPropertyOptional({
example: 10,
description: "Max deliveries per minute",
})
@IsOptional()
@IsInt()
@Min(1)
@Max(1000)
rateLimitPerMinute?: number;

@ApiPropertyOptional({ example: { "X-Custom-Header": "value" } })
@IsOptional()
@IsObject()
headers?: Record<string, string>;

@ApiPropertyOptional()
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}

export class UpdateWebhookSubscriptionDto {
@ApiPropertyOptional({ example: "https://example.com/new-webhook" })
@IsOptional()
@IsUrl({}, { message: "url must be a valid URL" })
@MaxLength(2048)
url?: string;

@ApiPropertyOptional({ example: ["portfolio.rebalanced"] })
@IsOptional()
@IsArray()
@IsString({ each: true })
events?: string[];

@ApiPropertyOptional({ enum: WebhookSubscriptionStatus })
@IsOptional()
@IsEnum(WebhookSubscriptionStatus)
status?: WebhookSubscriptionStatus;

@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;

@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
@Max(20)
maxRetries?: number;

@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(100)
@Max(60000)
retryDelayMs?: number;

@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
backoffMultiplier?: number;

@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(1000)
@Max(120000)
timeoutMs?: number;

@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
@Max(1000)
rateLimitPerMinute?: number;

@ApiPropertyOptional()
@IsOptional()
@IsObject()
headers?: Record<string, string>;
}

export class PublishWebhookEventDto {
@ApiProperty({ example: "portfolio.rebalanced" })
@IsString()
@MinLength(1)
@MaxLength(255)
eventType: string;

@ApiProperty({
example: { portfolioId: "abc", oldAllocation: {}, newAllocation: {} },
})
@IsObject()
payload: Record<string, any>;

@ApiPropertyOptional({ example: "portfolio-123" })
@IsOptional()
@IsString()
@MaxLength(255)
aggregateId?: string;

@ApiPropertyOptional()
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}

export class RetryDeadLetterDto {
@ApiPropertyOptional({ description: "Specific dead letter ID to retry" })
@IsOptional()
@IsString()
deadLetterId?: string;
}
61 changes: 61 additions & 0 deletions src/infrastructure/webhooks/entities/webhook-dead-letter.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from "typeorm";

@Entity("webhook_dead_letters")
export class WebhookDeadLetter {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column({ type: "uuid" })
@Index()
deliveryId: string;

@Column({ type: "uuid" })
@Index()
subscriptionId: string;

@Column({ type: "uuid" })
@Index()
eventId: string;

@Column({ type: "varchar", length: 2048 })
url: string;

@Column({ type: "jsonb" })
eventPayload: Record<string, any>;

@Column({ type: "jsonb" })
requestHeaders: Record<string, string>;

@Column({ type: "int", nullable: true })
lastStatusCode?: number;

@Column({ type: "text", nullable: true })
lastErrorMessage?: string;

@Column({ type: "int" })
totalAttempts: number;

@Column({ type: "varchar", length: 255 })
userId: string;

@Column({ type: "jsonb", nullable: true })
allAttempts?: Array<{
attempt: number;
statusCode?: number;
error?: string;
durationMs: number;
timestamp: string;
}>;

@Column({ type: "boolean", default: false })
retried: boolean;

@CreateDateColumn()
createdAt: Date;
}
Loading
Loading